diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000..6893dc38
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Require review/approval for any changes to GitHub Actions workflows
+/.github/workflows/ @AchoArnold
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..dd234131
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,141 @@
+# Copilot Instructions for httpSMS
+
+httpSMS is a service that turns an Android phone into an SMS gateway via an HTTP API. This is a monorepo with three components:
+
+- **`api/`** — Go backend (Fiber, GORM, PostgreSQL)
+- **`web/`** — Nuxt 2 frontend (Vue 2, Vuetify 2, TypeScript)
+- **`android/`** — Native Android app (Kotlin)
+
+## Build, Test, and Lint Commands
+
+### API (Go)
+
+```bash
+cd api
+
+# Development with hot-reload
+air
+
+# Build
+go build -o ./tmp/main.exe .
+
+# Run tests
+go test ./...
+
+# Run a single test
+go test ./pkg/services/ -run TestMessageService
+
+# Generate Swagger docs (required after changing API annotations)
+swag init --requiredByDefault --parseDependency --parseInternal
+
+# Pre-commit hooks run: go-fumpt, go-imports, go-lint, go-mod-tidy
+```
+
+### Web (Nuxt/Vue)
+
+```bash
+cd web
+
+# Install dependencies
+pnpm install
+
+# Development server (port 3000)
+pnpm dev
+
+# Lint (eslint + stylelint + prettier)
+pnpm lint
+
+# Auto-fix lint issues
+pnpm lintfix
+
+# Run tests (Jest)
+pnpm test
+
+# Static site generation (production build)
+pnpm run generate
+
+# Regenerate TypeScript API models from Swagger
+pnpm api:models
+```
+
+### Android (Kotlin)
+
+```bash
+cd android
+
+# Build
+./gradlew build
+
+# Debug APK
+./gradlew assembleDebug
+
+# Release APK
+./gradlew assembleRelease
+```
+
+### Docker (full stack)
+
+```bash
+# Start all services (PostgreSQL, Redis, API, Web)
+docker compose up --build
+# API at localhost:8000, Web at localhost:3000
+```
+
+## Architecture
+
+### API — Layered Architecture with Event-Driven Processing
+
+The API uses a **DI container** (`pkg/di/container.go`) that lazily initializes all services as singletons. The layered architecture flows as:
+
+**Handlers → Services → Repositories → GORM/PostgreSQL**
+
+- **Handlers** (`pkg/handlers/`) — Fiber HTTP handlers. Each has a `RegisterRoutes()` method and embeds a base `handler` struct with standardized response methods (`responseBadRequest`, `responseNotFound`, etc.).
+- **Services** (`pkg/services/`) — Business logic. Orchestrate repositories and dispatch events.
+- **Repositories** (`pkg/repositories/`) — Data access via GORM. Interfaces defined alongside GORM implementations (prefixed `gorm*`).
+- **Validators** (`pkg/validators/`) — One validator per handler, return `url.Values` for field errors.
+- **Entities** (`pkg/entities/`) — Domain models, auto-migrated by GORM.
+
+**Event system**: Uses CloudEvents spec (`cloudevents/sdk-go`). Events defined in `pkg/events/` (31 event types). Listeners in `pkg/listeners/` process events either synchronously or via Google Cloud Tasks queue (emulator mode for local dev).
+
+**Entry point**: `main.go` loads `.env` in local mode, creates the DI container, and starts Fiber on `APP_PORT`.
+
+### Web — Nuxt 2 Static SPA
+
+- **State management**: Single Vuex store (`store/index.ts`) — actions make API calls via Axios, mutations update state, getters expose computed values.
+- **Components**: Use `vue-property-decorator` class syntax with `@Component`, `@Prop`, `@Watch` decorators.
+- **API client**: Axios configured in `plugins/axios.ts` with Firebase bearer token auth and `x-api-key` header support.
+- **API models**: TypeScript types in `models/` are auto-generated from the Swagger spec via `swagger-typescript-api`.
+- **Auth**: Firebase Authentication (Email/Password, Google, GitHub) with `auth` and `guest` middleware for route guards.
+- **Real-time**: Pusher.js for live message updates.
+
+### Android — Task-Oriented, Event-Driven
+
+- **No MVVM/Clean Architecture** — uses a flat package structure with Activities, Services, BroadcastReceivers, and WorkManager tasks.
+- **FCM integration**: `MyFirebaseMessagingService` receives push notifications → schedules `SendSmsWorker` via WorkManager → fetches message from API → sends SMS.
+- **Dual SIM support**: Independent settings per SIM via `Settings` singleton (SharedPreferences).
+- **HTTP client**: OkHttp with `x-api-key` authentication against the API.
+- **Encryption**: AES-256/CFB with SHA-256 key derivation (`Encrypter.kt`).
+
+## Key Conventions
+
+### API (Go)
+
+- **Error handling**: Use `github.com/palantir/stacktrace` — wrap errors with `stacktrace.Propagate(err, "context")` or `stacktrace.PropagateWithCode()`. Never return bare errors.
+- **Database queries**: Always use GORM query builder with context propagation (`repository.db.WithContext(ctx)`). No raw SQL.
+- **Route registration**: Each handler defines `RegisterRoutes()` called from the DI container. Routes follow REST conventions under `/v1/`.
+- **Middleware chain**: HTTP Logger → OpenTelemetry → CORS → Request Logger → Bearer Auth → API Key Auth.
+- **Observability**: All layers are instrumented with OpenTelemetry (Fiber, GORM, Redis). Pass `logger` and `tracer` to constructors.
+- **Code formatting**: `go-fumpt` (not `gofmt`), enforced via pre-commit hooks.
+
+### Web (Vue/TypeScript)
+
+- **Formatting**: No semicolons, single quotes, 2-space indentation (Prettier + ESLint).
+- **Component style**: Class-based with `vue-property-decorator`, not Options API (though some pages use `Vue.extend()`).
+- **Store pattern**: Actions handle async API calls and commit mutations. Access store from components via `this.$store`.
+
+### Android (Kotlin)
+
+- **API calls**: Use `HttpSmsApiService` singleton (static `create()` factory). OkHttp client with `x-api-key` header.
+- **Background work**: Use WorkManager for tasks that must survive process death. Direct `Thread { }` for lightweight background ops.
+- **State**: `Settings` object (SharedPreferences singleton) for all persistent state.
+- **Phone number formatting**: Use `libphonenumber` for E.164 format validation.
diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml
new file mode 100644
index 00000000..849f5ec8
--- /dev/null
+++ b/.github/workflows/api.yml
@@ -0,0 +1,126 @@
+name: api
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ test:
+ name: Integration Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: stable
+
+ - name: Generate Firebase credentials
+ run: |
+ bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json
+ echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV
+
+ - name: Start Services
+ working-directory: ./tests
+ run: docker compose up -d --build
+
+ - name: Wait for services to be healthy
+ working-directory: ./tests
+ run: |
+ echo "Waiting for MongoDB to be healthy..."
+ for i in $(seq 1 20); do
+ if docker compose exec mongodb mongosh --eval "db.runCommand('ping').ok" --quiet >/dev/null 2>&1; then
+ echo "MongoDB is healthy!"
+ break
+ fi
+ if [ $i -eq 20 ]; then
+ echo "MongoDB failed to become healthy"
+ docker compose logs mongodb
+ exit 1
+ fi
+ echo "MongoDB attempt $i/20 - waiting 3s..."
+ sleep 3
+ done
+
+ echo "Waiting for API to be healthy..."
+ for i in $(seq 1 40); do
+ if docker compose exec api curl -sf http://localhost:8000/health >/dev/null 2>&1; then
+ echo "API is healthy!"
+ break
+ fi
+ if [ $i -eq 40 ]; then
+ echo "API failed to become healthy"
+ docker compose logs api
+ exit 1
+ fi
+ echo "Attempt $i/40 - waiting 5s..."
+ sleep 5
+ done
+
+ - name: Seed Database
+ working-directory: ./tests
+ run: |
+ echo "Waiting for seed container to finish..."
+ docker compose wait seed || true
+ sleep 2
+
+ - name: Run Integration Tests
+ working-directory: ./tests
+ run: go test -v -timeout 300s ./...
+
+ - name: Collect Logs on Failure
+ if: failure()
+ working-directory: ./tests
+ run: |
+ docker compose logs --tail 200
+
+ - name: Stop Services
+ if: always()
+ working-directory: ./tests
+ run: docker compose down -v
+
+ deploy:
+ name: Deploy
+ runs-on: ubuntu-latest
+ needs: test
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ steps:
+ - name: Authenticate to Google Cloud
+ uses: google-github-actions/auth@v3
+ with:
+ workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+
+ - name: Set up Cloud SDK
+ uses: google-github-actions/setup-gcloud@v3
+
+ - name: Trigger Cloud Build Deploy
+ run: |
+ BUILD_ID=$(gcloud builds triggers run api-httpsms-com \
+ --region=global \
+ --project=httpsms-86c51 \
+ --sha=${{ github.sha }} \
+ --format="value(metadata.build.id)")
+ echo -e "Cloud Build: \033[34mhttps://console.cloud.google.com/cloud-build/builds/$BUILD_ID?project=httpsms-86c51\033[0m"
+ echo ""
+ echo "Polling Cloud Build Status..."
+ while true; do
+ STATUS=$(gcloud builds describe "$BUILD_ID" --region=global --project=httpsms-86c51 --format="value(status)")
+ LOCAL_TIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
+ echo -e " \033[90m${LOCAL_TIME}\033[0m status=\033[36m${STATUS}\033[0m"
+ case "$STATUS" in
+ SUCCESS) echo -e "\033[32mBuild succeeded!\033[0m"; exit 0 ;;
+ FAILURE|TIMEOUT|CANCELLED|EXPIRED|INTERNAL_ERROR) echo -e "\033[31mBuild failed with status: $STATUS\033[0m"; exit 1 ;;
+ esac
+ sleep 30
+ done
diff --git a/.github/workflows/ci.yml b/.github/workflows/web.yml
similarity index 63%
rename from .github/workflows/ci.yml
rename to .github/workflows/web.yml
index 26d01123..ba4fe8ec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/web.yml
@@ -1,16 +1,25 @@
-name: ci
+name: web
on:
push:
branches:
- main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ pull-requests: write
+ deployments: write
defaults:
run:
working-directory: ./web
jobs:
- ci:
+ validate:
+ name: Validate
runs-on: ${{ matrix.os }}
strategy:
@@ -22,10 +31,10 @@ jobs:
- name: Checkout 🛎
uses: actions/checkout@master
- - uses: pnpm/action-setup@v4
+ - uses: pnpm/action-setup@v6
name: Install pnpm
with:
- version: 9
+ version: 10
- name: Install dependencies 📦
run: pnpm install
@@ -36,8 +45,26 @@ jobs:
- name: Run tests 🧪
run: pnpm test
- - name: Debug 🐛
- run: echo GITHUB_SHA=${GITHUB_SHA}
+ - name: Build 🏗️
+ run: mv .env.production .env && echo GITHUB_SHA=${GITHUB_SHA} >> .env && pnpm run generate
+
+ deploy:
+ name: Deploy
+ needs: validate
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout 🛎
+ uses: actions/checkout@master
+
+ - uses: pnpm/action-setup@v6
+ name: Install pnpm
+ with:
+ version: 10
+
+ - name: Install dependencies 📦
+ run: pnpm install
- name: Build 🏗️
run: mv .env.production .env && echo GITHUB_SHA=${GITHUB_SHA} >> .env && pnpm run generate
diff --git a/.gitignore b/.gitignore
index b114cdd0..e15d590a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,14 @@
android/app/debug/
*main.exe*
android/app/release/
+
+tests/firebase-credentials.json
+tests/emulator/emulator.exe
+SECURITY_AUDIT_REPORT.md
+
+*.exe
+
+docs/
+.output
+.agents/
+skills-lock.json
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..ef3d2cf0
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,27 @@
+{
+ "mcpServers": {
+ "playwright": {
+ "type": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-playwright",
+ "--base-url",
+ "http://localhost:3000"
+ ],
+ "env": {
+ "BROWSER": "chromium"
+ }
+ },
+ "context7": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["@upstash/context7-mcp@latest"]
+ },
+ "axiom": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "mcp-remote", "https://mcp.axiom.co/mcp"]
+ }
+ }
+}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6e658730..28ef7cf0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,6 +14,7 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/pre-commit/mirrors-prettier
- rev: v3.0.0
+ rev: v3.1.0
hooks:
- id: prettier
+ exclude: ^web/
diff --git a/README.md b/README.md
index f1d9a1b0..14690530 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# httpSMS
-[](https://github.com/NdoleStudio/httpsms/actions/workflows/ci.yml)
+[](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml)
+[](https://github.com/NdoleStudio/httpsms/actions/workflows/api.yml)
[](https://github.com/NdoleStudio/httpsms/graphs/contributors)
[](https://github.com/NdoleStudio/httpsms/blob/master/LICENSE)
[](CODE_OF_CONDUCT.md)
@@ -37,11 +38,13 @@ Quick Start Guide 👉 [https://docs.httpsms.com](https://docs.httpsms.com)
- [Self Host Setup - Docker](#self-host-setup---docker)
- [1. Setup Firebase](#1-setup-firebase)
- [2. Setup SMTP Email service](#2-setup-smtp-email-service)
- - [3. Download the code](#3-download-the-code)
- - [4. Setup the environment variables](#4-setup-the-environment-variables)
- - [5. Build and Run](#5-build-and-run)
- - [6. Create the System User](#6-create-the-system-user)
- - [7. Build the Android App.](#7-build-the-android-app)
+ - [3. Setup Cloudflare Turnstile](#3-setup-cloudflare-turnstile)
+ - [4. Download the code](#4-download-the-code)
+ - [5. Setup the environment variables](#5-setup-the-environment-variables)
+ - [6. Build and Run](#6-build-and-run)
+ - [7. Create the System User](#7-create-the-system-user)
+ - [8. Build the Android App.](#8-build-the-android-app)
+- [Integration Testing](#integration-testing)
- [License](#license)
@@ -164,7 +167,15 @@ const firebaseConfig = {
The httpSMS application uses [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) to send emails to users e.g. when your Android phone has been offline for a long period of time.
You can use a service like [mailtrap](https://mailtrap.io/) to create an SMTP server for development purposes.
-### 3. Download the code
+### 3. Setup Cloudflare Turnstile
+
+The message search route (`/v1/messages/search`) is protected by a [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/get-started/) captcha to prevent abuse. You need to set up a Turnstile widget for the search messages feature to work.
+
+1. Go to the [Cloudflare dashboard](https://dash.cloudflare.com/) and navigate to **Turnstile**.
+2. Add a new site and configure it for your self-hosted domain (e.g., `localhost` for local development).
+3. Note down the **Site Key** and **Secret Key** — you will need them for the frontend and backend environment variables respectively.
+
+### 4. Download the code
Clone the httpSMS GitHub repository
@@ -172,7 +183,7 @@ Clone the httpSMS GitHub repository
git clone https://github.com/NdoleStudio/httpsms.git
```
-### 4. Setup the environment variables
+### 5. Setup the environment variables
- Copy the `.env.docker` file in the `web` directory into `.env`
@@ -190,6 +201,9 @@ FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_MEASUREMENT_ID=
+
+# Cloudflare Turnstile site key from step 3
+CLOUDFLARE_TURNSTILE_SITE_KEY=
```
- Copy the `.env.docker` file in the `api` directory into `.env`
@@ -198,7 +212,7 @@ FIREBASE_MEASUREMENT_ID=
cp api/.env.docker api/.env
```
-- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials and SMTP server details.
+- Update the environment variables in the `.env` file in the `api` directory with your firebase service account credentials, SMTP server details, and Cloudflare Turnstile secret key.
```dotenv
# SMTP email server settings
@@ -212,11 +226,14 @@ FIREBASE_CREDENTIALS=
# This is the `projectId` from your firebase web config
GCP_PROJECT_ID=
+
+# Cloudflare Turnstile secret key from step 3
+CLOUDFLARE_TURNSTILE_SECRET_KEY=
```
- Don't bother about the `EVENTS_QUEUE_USER_API_KEY` and `EVENTS_QUEUE_USER_ID` settings. We will set that up later.
-### 5. Build and Run
+### 6. Build and Run
- Build and run the API, the web UI, database and cache using the `docker-compose.yml` file. It takes a while for build and download all the docker images.
When it's finished, you'll be able to access the web UI at http://localhost:3000 and the API at http://localhost:8000
@@ -225,15 +242,41 @@ GCP_PROJECT_ID=
docker compose up --build
```
-### 6. Create the System User
+### 7. Create the System User
+
+- The application uses the concept of a system user to process events async. You should manually create this user in `users` table in your database. Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file.
-- The application uses the concept of a system user to process events async. You should manually create this user in `users` table in your database.
- Make sure you use the same `id` and `api_key` as the `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file
+ ```SQL
+ INSERT INTO users (id, api_key, email ) VALUES ('your-system-user-id', 'your-system-api-key', 'system@domain.com');
+ ```
-### 7. Build the Android App.
+> [!IMPORTANT]
+> Restart your API docker container after modifying `EVENTS_QUEUE_USER_ID`, and `EVENTS_QUEUE_USER_API_KEY` in your `.env` file so that the httpSMS API can pick up the changes.
+
+### 8. Build the Android App.
- Before building the Android app in [Android Studio](https://developer.android.com/studio), you need to replace the `google-services.json` file in the `android/app` directory with the file which you got from step 1. You need to do this for the firebase FCM messages to work properly.
+## Integration Testing
+
+The project includes end-to-end integration tests that validate the complete SMS send/receive lifecycle. Tests run the full stack (API, PostgreSQL, Redis) in Docker alongside a phone emulator that simulates an Android device.
+
+📖 **Full documentation:** [`tests/README.md`](tests/README.md)
+
+**Quick run:**
+
+```bash
+cd tests
+bash generate-firebase-credentials.sh
+export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json)
+docker compose up -d --build --wait
+docker compose wait seed && sleep 2
+go test -v -timeout 120s ./...
+docker compose down -v
+```
+
+Integration tests also run automatically in CI on every push/PR to `main`.
+
## License
This project is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE Version 3 - see the [LICENSE](LICENSE) file for details
diff --git a/android/.gitignore b/android/.gitignore
index aa724b77..6c13541c 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -12,4 +12,5 @@
/captures
.externalNativeBuild
.cxx
+.kotlin/sessions/
local.properties
diff --git a/android/app/build.gradle b/android/app/build.gradle
deleted file mode 100644
index f49acb2b..00000000
--- a/android/app/build.gradle
+++ /dev/null
@@ -1,73 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
- id 'com.google.gms.google-services'
- id "io.sentry.android.gradle" version "4.3.1"
-}
-
-def getGitHash = { ->
- def stdout = new ByteArrayOutputStream()
- exec {
- commandLine 'git', 'rev-parse', '--short', 'HEAD'
- standardOutput = stdout
- }
- return stdout.toString().trim()
-}
-
-android {
- compileSdk 35
-
- defaultConfig {
- applicationId "com.httpsms"
- minSdk 28
- targetSdk 35
- versionCode 1
- versionName "${getGitHash()}"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- debug {
- manifestPlaceholders["sentryEnvironment"] = "development"
- }
- release {
- manifestPlaceholders["sentryEnvironment"] = "production"
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- }
- namespace 'com.httpsms'
-
- buildFeatures {
- buildConfig = true
- }
-}
-
-dependencies {
- implementation platform('com.google.firebase:firebase-bom:33.13.0')
- implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
- implementation 'com.google.firebase:firebase-analytics-ktx'
- implementation 'com.google.firebase:firebase-messaging-ktx'
- implementation 'com.squareup.okhttp3:okhttp:4.12.0'
- implementation 'com.jakewharton.timber:timber:5.0.1'
- implementation 'androidx.preference:preference-ktx:1.2.1'
- implementation 'androidx.work:work-runtime-ktx:2.10.1'
- implementation 'androidx.core:core-ktx:1.16.0'
- implementation "androidx.cardview:cardview:1.0.0"
- implementation 'com.beust:klaxon:5.6'
- implementation 'androidx.appcompat:appcompat:1.7.0'
- implementation 'org.apache.commons:commons-text:1.12.0'
- implementation 'com.google.android.material:material:1.12.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
- implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.4'
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.2.1'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
-}
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 00000000..15857e70
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,65 @@
+plugins {
+ id("com.android.application")
+ id("com.google.gms.google-services")
+ id("io.sentry.android.gradle") version "6.2.0"
+}
+
+val gitHash = providers.exec {
+ commandLine("git", "rev-parse", "--short", "HEAD")
+}.standardOutput.asText.map { it.trim() }
+
+android {
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.httpsms"
+ minSdk = 28
+ targetSdk = 36
+ versionCode = 1
+ versionName = gitHash.getOrElse("unknown")
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("debug") {
+ manifestPlaceholders["sentryEnvironment"] = "development"
+ }
+ getByName("release") {
+ manifestPlaceholders["sentryEnvironment"] = "production"
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ namespace = "com.httpsms"
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(platform("com.google.firebase:firebase-bom:34.11.0"))
+ implementation("com.journeyapps:zxing-android-embedded:4.3.0")
+ implementation("com.google.firebase:firebase-analytics")
+ implementation("com.google.firebase:firebase-messaging")
+ implementation("com.squareup.okhttp3:okhttp:5.3.2")
+ implementation("com.jakewharton.timber:timber:5.0.1")
+ implementation("androidx.preference:preference-ktx:1.2.1")
+ implementation("androidx.work:work-runtime-ktx:2.11.1")
+ implementation("androidx.core:core-ktx:1.18.0")
+ implementation("androidx.cardview:cardview:1.0.0")
+ implementation("com.beust:klaxon:5.6")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("org.apache.commons:commons-text:1.15.0")
+ implementation("com.google.android.material:material:1.13.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.1")
+ implementation("com.googlecode.libphonenumber:libphonenumber:9.0.26")
+ implementation("com.klinkerapps:android-smsmms:5.2.6")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.3.0")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6d704ade..86ca0a51 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
@@ -30,7 +31,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HttpSMS"
- tools:targetApi="31">
+ tools:targetApi="36">
+ android:name="com.journeyapps.barcodescanner.CaptureActivity"
+ android:screenOrientation="fullSensor"
+ tools:replace="screenOrientation"
+ tools:ignore="DiscouragedApi" />
+
+
+
+
@@ -90,6 +95,17 @@
+
+
+
+
+
diff --git a/android/app/src/main/java/com/httpsms/Constants.kt b/android/app/src/main/java/com/httpsms/Constants.kt
index fd8a0b90..ba3e1584 100644
--- a/android/app/src/main/java/com/httpsms/Constants.kt
+++ b/android/app/src/main/java/com/httpsms/Constants.kt
@@ -10,6 +10,7 @@ class Constants {
const val KEY_MESSAGE_TIMESTAMP = "KEY_MESSAGE_TIMESTAMP"
const val KEY_MESSAGE_REASON = "KEY_MESSAGE_REASON"
const val KEY_MESSAGE_ENCRYPTED = "KEY_MESSAGE_ENCRYPTED"
+ const val KEY_MESSAGE_ATTACHMENTS = "KEY_MESSAGE_ATTACHMENTS"
const val KEY_HEARTBEAT_ID = "KEY_HEARTBEAT_ID"
@@ -18,5 +19,7 @@ class Constants {
const val SIM2 = "SIM2"
const val TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'000000'ZZZZZ"
+
+ const val MAX_MMS_ATTACHMENT_SIZE: Long = (3L * 1024 * 1024) / 2
}
}
diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
index 8f1e448c..e4113289 100644
--- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
+++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
@@ -9,6 +9,15 @@ import com.google.firebase.messaging.RemoteMessage
import com.httpsms.SentReceiver.FailedMessageWorker
import timber.log.Timber
+import com.google.android.mms.pdu_alt.CharacterSets
+import com.google.android.mms.pdu_alt.EncodedStringValue
+import com.google.android.mms.pdu_alt.PduBody
+import com.google.android.mms.pdu_alt.PduComposer
+import com.google.android.mms.pdu_alt.PduPart
+import com.google.android.mms.pdu_alt.SendReq
+import okhttp3.MediaType
+import java.io.File
+
class MyFirebaseMessagingService : FirebaseMessagingService() {
// [START receive_message]
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -158,6 +167,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
Receiver.register(applicationContext)
+
+ if (message.attachments != null && message.attachments.isNotEmpty()) {
+ return handleMmsMessage(message)
+ }
+
val parts = getMessageParts(applicationContext, message)
if (parts.size == 1) {
return handleSingleMessage(message, parts.first())
@@ -165,6 +179,143 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
return handleMultipartMessage(message, parts)
}
+ fun extractFileName(url: String, prefix: String, mimeType: String? = null): String {
+ val fileName = url.substringAfterLast("/")
+ .substringBefore("?")
+ .takeIf { it.isNotBlank() && it.contains(".") }
+ ?: run {
+ val extension = mimeType?.let { mime ->
+ val ext = mime.substringAfterLast("/")
+ if (ext.isNotBlank()) ".$ext" else ".bin"
+ } ?: ""
+ "attachment$extension"
+ }
+
+ return "${prefix}_$fileName"
+ }
+
+ private fun handleMmsMessage(message: Message): Result {
+ Timber.d("Processing MMS for message ID [${message.id}]")
+ val apiService = HttpSmsApiService.create(applicationContext)
+
+ val downloadedFiles = mutableListOf>()
+
+ try {
+ for ((index, attachment) in message.attachments!!.withIndex()) {
+ val file = apiService.downloadAttachment(applicationContext, attachment, message.id, index)
+ if (file.first == null || file.second == null) {
+ handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.")
+ return Result.failure()
+ }
+ downloadedFiles.add(Pair(file.first!!, file.second!!))
+ }
+
+ val sendReq = SendReq()
+
+ val encodedContact = EncodedStringValue(message.contact)
+ sendReq.to = arrayOf(encodedContact)
+
+ val pduBody = PduBody()
+
+ if (message.content.isNotEmpty()) {
+ val textPart = PduPart()
+ textPart.setCharset(CharacterSets.UTF_8)
+ textPart.contentType = "text/plain".toByteArray()
+ textPart.name = "text".toByteArray()
+ textPart.contentId = "text".toByteArray()
+ textPart.contentLocation = "text".toByteArray()
+
+ var messageBody = message.content
+ val encryptionKey = Settings.getEncryptionKey(applicationContext)
+ if (message.encrypted && !encryptionKey.isNullOrEmpty()) {
+ messageBody = Encrypter.decrypt(encryptionKey, messageBody)
+ }
+ textPart.data = messageBody.toByteArray(Charsets.UTF_8)
+
+ pduBody.addPart(textPart)
+ }
+
+ for ((index, file) in downloadedFiles.withIndex()) {
+ val fileBytes = file.first.readBytes()
+
+ val mediaPart = PduPart()
+ mediaPart.contentType = file.second.toString().toByteArray()
+
+
+ val fileName = extractFileName(message.attachments[index], index.toString(), file.second.toString())
+ mediaPart.name = fileName.toByteArray()
+ mediaPart.contentId = fileName.toByteArray()
+ mediaPart.contentLocation = fileName.toByteArray()
+ mediaPart.data = fileBytes
+
+ Timber.d("Adding MMS attachment with name [$fileName] and size [${fileBytes.size}] and type [${file.second}]")
+
+ pduBody.addPart(mediaPart)
+ }
+
+ sendReq.body = pduBody
+
+ val pduComposer = PduComposer(applicationContext, sendReq)
+ val pduBytes = pduComposer.make()
+
+ if (pduBytes == null) {
+ Timber.e("PduComposer failed to generate PDU byte array")
+ handleFailed(applicationContext, message.id, "Failed to compose MMS PDU.")
+ return Result.failure()
+ }
+
+ val mmsDir = java.io.File(applicationContext.cacheDir, "mms_attachments")
+ if (!mmsDir.exists()) {
+ mmsDir.mkdirs()
+ }
+
+ val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat")
+ java.io.FileOutputStream(pduFile).use { it.write(pduBytes) }
+
+ val pduUri = androidx.core.content.FileProvider.getUriForFile(
+ applicationContext,
+ "${BuildConfig.APPLICATION_ID}.fileprovider",
+ pduFile
+ )
+
+ val sentIntent = createPendingIntent(message.id, SmsManagerService.sentAction())
+ SmsManagerService().sendMultimediaMessage(applicationContext, pduUri, message.sim, sentIntent)
+
+ Timber.d("Successfully dispatched MMS for message ID [${message.id}]")
+ return Result.success()
+
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to send MMS for message ID [${message.id}]")
+ handleFailed(applicationContext, message.id, e.message ?: "Internal error while building or sending MMS.")
+ return Result.failure()
+ } finally {
+ // Clean up any downloaded temporary files
+ downloadedFiles.forEach { file ->
+ if (file.first.exists()) {
+ file.first.delete()
+ }
+ }
+
+ // Also clean up the MMS PDU file to avoid cache buildup in cases where
+ // sendMultimediaMessage fails before the sent broadcast is delivered.
+ try {
+ // The PDU file is stored under the "mms_attachments" cache subdirectory;
+ // delete it from the same location to ensure cleanup is effective.
+ val pduDir = File(applicationContext.cacheDir, "mms_attachments")
+ val pduFile = File(pduDir, "pdu_${message.id}.dat")
+ if (pduFile.exists()) {
+ val deleted = pduFile.delete()
+ if (!deleted) {
+ Timber.w("Failed to delete MMS PDU file for message ID [${message.id}] at [${pduFile.absolutePath}]")
+ }
+ }
+ } catch (cleanupException: Exception) {
+ // Best-effort cleanup; log but do not change the original result.
+ Timber.w(cleanupException, "Error while cleaning up MMS PDU file for message ID [${message.id}]")
+ }
+ }
+ }
+
private fun handleMultipartMessage(message:Message, parts: ArrayList): Result {
Timber.d("sending multipart SMS for message with ID [${message.id}]")
return try {
diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
index 3d813e13..51fa21cd 100644
--- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
+++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
@@ -1,12 +1,18 @@
package com.httpsms
import android.content.Context
+import com.httpsms.Constants.Companion.MAX_MMS_ATTACHMENT_SIZE
+import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
-import org.apache.commons.text.StringEscapeUtils
import timber.log.Timber
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
import java.net.URI
import java.net.URL
import java.util.logging.Level
@@ -68,17 +74,8 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
return sendEvent(messageId, "FAILED", timestamp, reason)
}
- fun receive(sim: String, from: String, to: String, content: String, encrypted: Boolean, timestamp: String): Boolean {
- val body = """
- {
- "content": "${StringEscapeUtils.escapeJson(content)}",
- "sim": "$sim",
- "from": "$from",
- "timestamp": "$timestamp",
- "encrypted": $encrypted,
- "to": "$to"
- }
- """.trimIndent()
+ fun receive(requestPayload: ReceivedMessageRequest): Boolean {
+ val body = com.beust.klaxon.Klaxon().toJsonString(requestPayload)
val request: Request = Request.Builder()
.url(resolveURL("/v1/messages/receive"))
@@ -87,16 +84,21 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
.header(clientVersionHeader, BuildConfig.VERSION_NAME)
.build()
- val response = client.newCall(request).execute()
+ val response = try {
+ client.newCall(request).execute()
+ } catch (e: Exception) {
+ Timber.e(e, "Exception while sending received message request")
+ return false
+ }
+
if (!response.isSuccessful) {
- Timber.e("error response [${response.body?.string()}] with code [${response.code}] while receiving message [${body}]")
+ Timber.e("error response [${response.body?.string()}] with code [${response.code}] while receiving message")
response.close()
return response.code in 400..499
}
- val message = ResponseMessage.fromJson(response.body!!.string())
response.close()
- Timber.i("received message stored successfully for message with ID [${message?.data?.id}]" )
+ Timber.i("received message stored successfully")
return true
}
@@ -156,6 +158,65 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
return true
}
+ fun InputStream.copyToWithLimit(
+ out: OutputStream,
+ limit: Long,
+ bufferSize: Int = DEFAULT_BUFFER_SIZE
+ ): Long {
+ var bytesCopied: Long = 0
+ val buffer = ByteArray(bufferSize)
+ var bytes = read(buffer)
+
+ while (bytes >= 0) {
+ bytesCopied += bytes
+
+ if (bytesCopied > limit) {
+ throw IOException("Download aborted: File exceeded maximum allowed size of $limit bytes.")
+ }
+
+ out.write(buffer, 0, bytes)
+ bytes = read(buffer)
+ }
+ return bytesCopied
+ }
+
+ fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): Pair {
+ val request = Request.Builder().url(urlString).build()
+
+ try {
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ Timber.e("Failed to download attachment: ${response.code}")
+ return Pair(null, null)
+ }
+
+ val body = response.body
+ val contentLength = body.contentLength()
+ if (contentLength > MAX_MMS_ATTACHMENT_SIZE) {
+ Timber.e("Attachment is too large ($contentLength bytes).")
+ return Pair(null, null)
+ }
+
+ val mmsDir = File(context.cacheDir, "mms_attachments")
+ if (!mmsDir.exists()) {
+ mmsDir.mkdirs()
+ }
+
+ val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex")
+ val inputStream = body.byteStream()
+ FileOutputStream(tempFile).use { outputStream ->
+ inputStream.use { input ->
+ input.copyToWithLimit(outputStream, MAX_MMS_ATTACHMENT_SIZE)
+ }
+ }
+
+ return Pair(tempFile, body.contentType())
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Exception while download attachment")
+ return Pair(null, null)
+ }
+ }
private fun sendEvent(messageId: String, event: String, timestamp: String, reason: String? = null): Boolean {
var reasonString = "null"
@@ -186,7 +247,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
}
if (!response.isSuccessful) {
- Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]")
+ Timber.e("error response [${response.body.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]")
response.close()
return false
}
diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt
index 5f76ada8..363e7c19 100644
--- a/android/app/src/main/java/com/httpsms/MainActivity.kt
+++ b/android/app/src/main/java/com/httpsms/MainActivity.kt
@@ -6,6 +6,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -28,7 +29,6 @@ import com.google.android.material.card.MaterialCardView
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.httpsms.services.StickyNotificationService
import com.httpsms.worker.HeartbeatWorker
-import okhttp3.internal.format
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
@@ -60,6 +60,7 @@ class MainActivity : AppCompatActivity() {
scheduleHeartbeatWorker(this)
setVersion()
setHeartbeatListener(this)
+ setSmsPermissionListener()
setBatteryOptimizationListener()
}
@@ -74,12 +75,13 @@ class MainActivity : AppCompatActivity() {
redirectToLogin()
refreshToken(this)
setCardContent(this)
+ setSmsPermissionListener()
setBatteryOptimizationListener()
}
private fun setVersion() {
val appVersionView = findViewById(R.id.mainAppVersion)
- appVersionView.text = format(getString(R.string.app_version), BuildConfig.VERSION_NAME)
+ appVersionView.text = getString(R.string.app_version, BuildConfig.VERSION_NAME)
}
private fun setCardContent(context: Context) {
@@ -114,6 +116,7 @@ class MainActivity : AppCompatActivity() {
Settings.setIncomingCallEventsEnabled(context, Constants.SIM2, false)
}
}
+ setSmsPermissionListener()
}
var permissions = arrayOf(
@@ -283,8 +286,9 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("BatteryLife")
private fun setBatteryOptimizationListener() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
+ val button = findViewById(R.id.batteryOptimizationButtonButton)
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
- val button = findViewById(R.id.batteryOptimizationButtonButton)
+ button.visibility = View.VISIBLE
button.setOnClickListener {
val intent = Intent()
intent.action = ProviderSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
@@ -292,8 +296,43 @@ class MainActivity : AppCompatActivity() {
startActivity(intent)
}
} else {
- val layout = findViewById(R.id.batteryOptimizationLinearLayout)
+ button.visibility = View.GONE
+ }
+ updatePermissionLayoutVisibility()
+ }
+
+ private fun setSmsPermissionListener() {
+ val smsPermissions = arrayOf(
+ Manifest.permission.SEND_SMS,
+ Manifest.permission.RECEIVE_SMS,
+ Manifest.permission.READ_SMS
+ )
+ val allGranted = smsPermissions.all {
+ checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ val button = findViewById(R.id.smsPermissionButton)
+ if (!allGranted) {
+ button.visibility = View.VISIBLE
+ button.setOnClickListener {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://httpsms.com/blog/grant-send-and-read-sms-permissions-on-android"))
+ startActivity(intent)
+ }
+ } else {
+ button.visibility = View.GONE
+ }
+ updatePermissionLayoutVisibility()
+ }
+
+ private fun updatePermissionLayoutVisibility() {
+ val smsButton = findViewById(R.id.smsPermissionButton)
+ val batteryButton = findViewById(R.id.batteryOptimizationButtonButton)
+ val layout = findViewById(R.id.batteryOptimizationLinearLayout)
+
+ if (smsButton.visibility == View.GONE && batteryButton.visibility == View.GONE) {
layout.visibility = View.GONE
+ } else {
+ layout.visibility = View.VISIBLE
}
}
diff --git a/android/app/src/main/java/com/httpsms/Models.kt b/android/app/src/main/java/com/httpsms/Models.kt
index ccfe590b..b4bf5464 100644
--- a/android/app/src/main/java/com/httpsms/Models.kt
+++ b/android/app/src/main/java/com/httpsms/Models.kt
@@ -68,5 +68,24 @@ data class Message (
val type: String,
@Json(name = "updated_at")
- val updatedAt: String
+ val updatedAt: String,
+
+ val attachments: List? = null
+)
+
+data class ReceivedAttachment(
+ val name: String,
+ @Json(name = "content_type")
+ val contentType: String,
+ val content: String
+)
+
+data class ReceivedMessageRequest(
+ val sim: String,
+ val from: String,
+ val to: String,
+ val content: String,
+ val encrypted: Boolean,
+ val timestamp: String,
+ val attachments: List? = null
)
diff --git a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
index 9d0f3d83..3edc30e2 100644
--- a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
+++ b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
@@ -4,7 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
-import androidx.work.BackoffPolicy
+import android.util.Base64
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
@@ -13,20 +13,30 @@ import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
+import com.google.android.mms.pdu_alt.CharacterSets
+import com.google.android.mms.pdu_alt.MultimediaMessagePdu
+import com.google.android.mms.pdu_alt.PduParser
+import com.google.android.mms.pdu_alt.RetrieveConf
import timber.log.Timber
+import java.io.File
+import java.io.FileOutputStream
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
-import java.util.concurrent.TimeUnit
class ReceivedReceiver: BroadcastReceiver()
{
- override fun onReceive(context: Context,intent: Intent) {
- if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
+ handleSmsReceived(context, intent)
+ } else if (intent.action == Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION) {
+ handleMmsReceived(context, intent)
+ } else {
Timber.e("received invalid intent with action [${intent.action}]")
- return
}
+ }
+ private fun handleSmsReceived(context: Context, intent: Intent) {
var smsSender = ""
var smsBody = ""
@@ -35,12 +45,7 @@ class ReceivedReceiver: BroadcastReceiver()
smsBody += smsMessage.messageBody
}
- var sim = Constants.SIM1
- var owner = Settings.getSIM1PhoneNumber(context)
- if (intent.getIntExtra("android.telephony.extra.SLOT_INDEX", 0) > 0 && Settings.isDualSIM(context)) {
- owner = Settings.getSIM2PhoneNumber(context)
- sim = Constants.SIM2
- }
+ val (sim, owner) = getSimAndOwner(context, intent)
if (!Settings.isIncomingMessageEnabled(context, sim)) {
Timber.w("[${sim}] is not active for incoming messages")
@@ -56,7 +61,71 @@ class ReceivedReceiver: BroadcastReceiver()
)
}
- private fun handleMessageReceived(context: Context, sim: String, from: String, to : String, content: String) {
+ private fun handleMmsReceived(context: Context, intent: Intent) {
+ val pushData = intent.getByteArrayExtra("data") ?: return
+ val pdu = PduParser(pushData, true).parse() ?: return
+
+ if (pdu !is MultimediaMessagePdu) {
+ Timber.d("Received PDU is not a MultimediaMessagePdu, ignoring.")
+ return
+ }
+
+ val from = pdu.from?.string ?: ""
+ var content = ""
+ val attachmentFiles = mutableListOf()
+
+ // Check if it's a RetrieveConf (which contains the actual message body)
+ if (pdu is RetrieveConf) {
+ val body = pdu.body
+ if (body != null) {
+ for (i in 0 until body.partsNum) {
+ val part = body.getPart(i)
+ val partData = part.data ?: continue
+ val contentType = String(part.contentType ?: "application/octet-stream".toByteArray())
+
+ if (contentType.startsWith("text/plain")) {
+ content += String(partData, charset(CharacterSets.getMimeName(part.charset)))
+ } else {
+ // Save attachment to a temporary file
+ val fileName = String(part.name ?: part.contentLocation ?: part.contentId ?: "attachment_$i".toByteArray())
+ val tempFile = File(context.cacheDir, "received_mms_${System.currentTimeMillis()}_$i")
+ FileOutputStream(tempFile).use { it.write(partData) }
+ attachmentFiles.add("${tempFile.absolutePath}|${contentType}|${fileName}")
+ }
+ }
+ }
+ } else {
+ Timber.d("Received PDU is of type [${pdu.javaClass.simpleName}], body extraction not implemented.")
+ }
+
+ val (sim, owner) = getSimAndOwner(context, intent)
+
+ if (!Settings.isIncomingMessageEnabled(context, sim)) {
+ Timber.w("[${sim}] is not active for incoming messages")
+ return
+ }
+
+ handleMessageReceived(
+ context,
+ sim,
+ from,
+ owner,
+ content,
+ attachmentFiles.toTypedArray()
+ )
+ }
+
+ private fun getSimAndOwner(context: Context, intent: Intent): Pair {
+ var sim = Constants.SIM1
+ var owner = Settings.getSIM1PhoneNumber(context)
+ if (intent.getIntExtra("android.telephony.extra.SLOT_INDEX", 0) > 0 && Settings.isDualSIM(context)) {
+ owner = Settings.getSIM2PhoneNumber(context)
+ sim = Constants.SIM2
+ }
+ return Pair(sim, owner)
+ }
+
+ private fun handleMessageReceived(context: Context, sim: String, from: String, to : String, content: String, attachments: Array? = null) {
val timestamp = ZonedDateTime.now(ZoneOffset.UTC)
if (!Settings.isLoggedIn(context)) {
@@ -84,7 +153,8 @@ class ReceivedReceiver: BroadcastReceiver()
Constants.KEY_MESSAGE_SIM to sim,
Constants.KEY_MESSAGE_CONTENT to body,
Constants.KEY_MESSAGE_ENCRYPTED to Settings.encryptReceivedMessages(context),
- Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z")
+ Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z"),
+ Constants.KEY_MESSAGE_ATTACHMENTS to attachments
)
val work = OneTimeWorkRequest
@@ -104,14 +174,52 @@ class ReceivedReceiver: BroadcastReceiver()
override fun doWork(): Result {
Timber.i("[${this.inputData.getString(Constants.KEY_MESSAGE_SIM)}] forwarding received message from [${this.inputData.getString(Constants.KEY_MESSAGE_FROM)}] to [${this.inputData.getString(Constants.KEY_MESSAGE_TO)}]")
- if (HttpSmsApiService.create(applicationContext).receive(
- this.inputData.getString(Constants.KEY_MESSAGE_SIM)!!,
- this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!,
- this.inputData.getString(Constants.KEY_MESSAGE_TO)!!,
- this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!!,
- this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false),
- this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!,
- )) {
+ val sim = this.inputData.getString(Constants.KEY_MESSAGE_SIM)!!
+ val from = this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!
+ val to = this.inputData.getString(Constants.KEY_MESSAGE_TO)!!
+ val content = this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!!
+ val encrypted = this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false)
+ val timestamp = this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!
+
+ val attachmentsData = inputData.getStringArray(Constants.KEY_MESSAGE_ATTACHMENTS)
+ val attachments = attachmentsData?.mapNotNull {
+ val parts = it.split("|")
+ val file = File(parts[0])
+ if (file.exists()) {
+ val bytes = file.readBytes()
+ val base64Content = Base64.encodeToString(bytes, Base64.NO_WRAP)
+ ReceivedAttachment(
+ name = parts[2],
+ contentType = parts[1],
+ content = base64Content
+ )
+ } else {
+ null
+ }
+ }
+
+ val request = ReceivedMessageRequest(
+ sim = sim,
+ from = from,
+ to = to,
+ content = content,
+ encrypted = encrypted,
+ timestamp = timestamp,
+ attachments = attachments
+ )
+
+ val success = HttpSmsApiService.create(applicationContext).receive(request)
+
+ // Cleanup temp files
+ attachmentsData?.forEach {
+ val path = it.split("|")[0]
+ val file = File(path)
+ if (file.exists()) {
+ file.delete()
+ }
+ }
+
+ if (success) {
return Result.success()
}
diff --git a/android/app/src/main/java/com/httpsms/SentReceiver.kt b/android/app/src/main/java/com/httpsms/SentReceiver.kt
index 7995c35c..2b5bfd12 100644
--- a/android/app/src/main/java/com/httpsms/SentReceiver.kt
+++ b/android/app/src/main/java/com/httpsms/SentReceiver.kt
@@ -14,16 +14,40 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import timber.log.Timber
+import java.io.File
internal class SentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
+ val messageId = intent.getStringExtra(Constants.KEY_MESSAGE_ID)
+ cleanupPduFile(context, messageId)
when (resultCode) {
Activity.RESULT_OK -> handleMessageSent(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID))
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "GENERIC_FAILURE")
SmsManager.RESULT_ERROR_NO_SERVICE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "NO_SERVICE")
SmsManager.RESULT_ERROR_NULL_PDU -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "NULL_PDU")
SmsManager.RESULT_ERROR_RADIO_OFF -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "RADIO_OFF")
- else -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "UNKNOWN")
+ SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "LIMIT_EXCEEDED")
+ else -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "UNKNOWN:${resultCode}")
+ }
+ }
+
+ private fun cleanupPduFile(context: Context, messageId: String?) {
+ if (messageId == null) return
+
+ try {
+ val baseMessageId = messageId.substringBefore(".")
+ val mmsDir = File(context.cacheDir, "mms_attachments")
+ val pduFile = File(mmsDir, "pdu_$baseMessageId.dat")
+
+ if (pduFile.exists()) {
+ if (pduFile.delete()) {
+ Timber.d("Cleaned up PDU file for message ID [$baseMessageId]")
+ } else {
+ Timber.w("Failed to delete PDU file for message ID [$baseMessageId]")
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Error cleaning up PDU file for message ID [$messageId]")
}
}
diff --git a/android/app/src/main/java/com/httpsms/SmsManagerService.kt b/android/app/src/main/java/com/httpsms/SmsManagerService.kt
index 17987b5c..c96a90a0 100644
--- a/android/app/src/main/java/com/httpsms/SmsManagerService.kt
+++ b/android/app/src/main/java/com/httpsms/SmsManagerService.kt
@@ -62,7 +62,7 @@ class SmsManagerService {
}
Timber.d("active subscription info size: [${localSubscriptionManager.activeSubscriptionInfoList!!.size}]")
- val subscriptionId = if (sim == Constants.SIM1 && localSubscriptionManager.activeSubscriptionInfoList!!.size > 0) {
+ val subscriptionId = if (sim == Constants.SIM1 && localSubscriptionManager.activeSubscriptionInfoList!!.isNotEmpty()) {
localSubscriptionManager.activeSubscriptionInfoList!![0].subscriptionId
} else if (sim == Constants.SIM2 && localSubscriptionManager.activeSubscriptionInfoList!!.size > 1) {
localSubscriptionManager.activeSubscriptionInfoList!![1].subscriptionId
@@ -76,4 +76,10 @@ class SmsManagerService {
context.getSystemService(SmsManager::class.java).createForSubscriptionId(subscriptionId)
}
}
+
+ // Wrapper for the smsManager's sendMultimediaMessage
+ fun sendMultimediaMessage(context: Context, pduUri: android.net.Uri, sim: String, sentIntent: PendingIntent) {
+ val smsManager = getSmsManager(context, sim)
+ smsManager.sendMultimediaMessage(context, pduUri, null, null, sentIntent)
+ }
}
diff --git a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
index ab83a3ec..174f2742 100644
--- a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
+++ b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
@@ -29,11 +29,16 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor
return Result.success()
}
- HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext))
- Timber.d("finished sending heartbeats to server")
+ try{
+ HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext))
+ Timber.d("finished sending heartbeats to server")
- Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
- Timber.d("Set the heartbeat timestamp")
+ Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
+ Timber.d("Set the heartbeat timestamp")
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to send [${phoneNumbers.joinToString()}] heartbeats to server")
+ return Result.failure()
+ }
return Result.success()
}
diff --git a/android/app/src/main/res/drawable/open_in_new_24.xml b/android/app/src/main/res/drawable/open_in_new_24.xml
new file mode 100644
index 00000000..b257c344
--- /dev/null
+++ b/android/app/src/main/res/drawable/open_in_new_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml
index cfd86db6..03552be5 100644
--- a/android/app/src/main/res/layout/activity_login.xml
+++ b/android/app/src/main/res/layout/activity_login.xml
@@ -1,174 +1,188 @@
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
-
-
+ android:layout_marginTop="32dp"
+ android:layout_marginBottom="24dp"
+ android:autoLink="web"
+ android:lineHeight="28sp"
+ android:text="@string/get_your_api_key"
+ android:textAlignment="center"
+ android:textSize="20sp"
+ app:layout_constraintBottom_toTopOf="@+id/loginApiKeyTextInputLayout"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/imageView"
+ app:layout_constraintVertical_bias="0"
+ app:layout_constraintVertical_chainStyle="packed" />
-
-
-
+ android:hint="@string/text_area_api_key"
+ app:errorEnabled="true"
+ app:endIconMode="custom"
+ app:endIconDrawable="@android:drawable/ic_menu_camera"
+ app:endIconContentDescription="cameraButton"
+ app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM1"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/textView">
-
+
-
-
-
+
+
+ android:layout_marginTop="8dp"
+ android:hint="@string/login_phone_number_sim1"
+ app:errorEnabled="true"
+ app:placeholderText="@string/login_phone_number_hint"
+ app:layout_constraintBottom_toTopOf="@+id/loginPhoneNumberLayoutSIM2"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginApiKeyTextInputLayout">
-
+
+
+
-
+ app:placeholderText="@string/login_phone_number_hint"
+ app:layout_constraintBottom_toTopOf="@+id/loginServerUrlLayoutContainer"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginPhoneNumberLayoutSIM1">
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+ android:layout_marginTop="16dp"
+ android:orientation="vertical"
+ android:gravity="center"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/loginServerUrlLayoutContainer">
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index d04b9150..75849475 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -3,9 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:paddingLeft="16dp"
- android:paddingRight="16dp"
android:layout_height="match_parent"
+ android:fitsSystemWindows="true"
tools:context=".MainActivity">
-
-
@@ -46,8 +37,6 @@
android:orientation="vertical"
android:padding="16dp">
-
-
-
@@ -96,8 +86,6 @@
android:orientation="vertical"
android:padding="16dp">
-
-
-
+
+
+ app:indicatorColor="@color/pink_500" />
+
+
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true">
@@ -30,8 +27,10 @@
+ android:layout_height="0dp"
+ android:fillViewport="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/settings_app_bar_layout">
+ android:paddingRight="16dp">
- #121212
- - true
+ - false
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 24e9609d..02dfa1b6 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -7,7 +7,7 @@
Login With API Key
API Key
HTTP Sms Logo
- Open\nhttpsms.com/settings\nto get your API key
+ Get Your API Key at\nhttpsms.com/settings
Log Out
e.g +18005550199 (international format)
e.g https://api.httpsms.com
@@ -17,6 +17,7 @@
https://api.httpsms.com
httpsms.com - %s
Disable Battery Optimization
+ Enable SMS Permission
App Settings
SIM1
SIM2
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
index 538ca49c..5914accc 100644
--- a/android/app/src/main/res/values/themes.xml
+++ b/android/app/src/main/res/values/themes.xml
@@ -13,7 +13,7 @@
- #121212
- - true
+ - false
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..0df3af41
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
deleted file mode 100644
index e29386c3..00000000
--- a/android/build.gradle
+++ /dev/null
@@ -1,27 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-buildscript {
- ext {
- kotlin_version = '2.1.0'
- }
- repositories {
- // Check that you have the following line (if not, add it):
- google()
- mavenCentral() // Google's Maven repository
-
- }
- dependencies {
- // Add this line
- classpath 'com.google.gms:google-services:4.4.2'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- }
-}
-
-plugins {
- id 'com.android.application' version '8.9.2' apply false
- id 'com.android.library' version '8.9.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
-}
-
-tasks.register('clean', Delete) {
- delete rootProject.buildDir
-}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 00000000..ee8d1b8d
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,19 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.google.gms:google-services:4.4.2")
+ }
+}
+
+plugins {
+ id("com.android.application") version "9.2.1" apply false
+ id("com.android.library") version "9.2.1" apply false
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
index cf0008dd..1f124546 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -22,3 +22,11 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
+android.defaults.buildfeatures.resvalues=true
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.enableAppCompileTimeRClass=false
+android.usesSdkInManifest.disallowed=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index f40abbca..ff340ba9 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Thu Jun 23 15:32:32 EEST 2022
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
diff --git a/android/settings.gradle b/android/settings.gradle.kts
similarity index 95%
rename from android/settings.gradle
rename to android/settings.gradle.kts
index baf72e29..75be430a 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle.kts
@@ -13,4 +13,4 @@ dependencyResolutionManagement {
}
}
rootProject.name = "httpSMS"
-include ':app'
+include(":app")
diff --git a/api/.air.toml b/api/.air.toml
deleted file mode 100644
index 15d45a05..00000000
--- a/api/.air.toml
+++ /dev/null
@@ -1,36 +0,0 @@
-root = "."
-testdata_dir = "testdata"
-tmp_dir = "tmp"
-
-[build]
- bin = "tmp\\main.exe"
- cmd = "go build -o ./tmp/main.exe ."
- delay = 1000
- exclude_dir = ["assets", "tmp", "vendor", "testdata"]
- exclude_file = []
- exclude_regex = ["_test.go"]
- exclude_unchanged = false
- follow_symlink = false
- full_bin = ""
- include_dir = []
- include_ext = ["go", "tpl", "tmpl", "html"]
- kill_delay = "0s"
- log = "build-errors.log"
- send_interrupt = false
- stop_on_error = true
-
-[color]
- app = ""
- build = "yellow"
- main = "magenta"
- runner = "green"
- watcher = "cyan"
-
-[log]
- time = false
-
-[misc]
- clean_on_exit = false
-
-[screen]
- clear_on_rebuild = false
diff --git a/api/.env.docker b/api/.env.docker
index 9dc43fdb..235972f4 100644
--- a/api/.env.docker
+++ b/api/.env.docker
@@ -1,10 +1,14 @@
-ENV=production
+ENV=local
# This is the project-id of the firebase project you created in the setup instructions
GCP_PROJECT_ID=httpsms-docker
USE_HTTP_LOGGER=true
+# Set to "true" to enable feature entitlement checks (limits for free users).
+# Defaults to "false" for self-hosted deployments (no limits).
+ENTITLEMENT_ENABLED=false
+
EVENTS_QUEUE_TYPE=emulator
EVENTS_QUEUE_NAME=events-local
EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events
@@ -48,9 +52,16 @@ DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms
# Redis connection string
REDIS_URL=redis://@redis:6379
-# [optional] If you would like to use uptrace.dev for distributed tracing, you can set the DSN here.
-# This is optional and you can leave it empty if you don't want to use uptrace
-UPTRACE_DSN=
+# Google Cloud Storage bucket for MMS attachments. Leave empty to use in-memory storage.
+GCS_BUCKET_NAME=
+
+# [Optional] Axiom observability configuration
+# API token for Axiom (required for logging, traces, and metrics in production)
+AXIOM_TOKEN=
+# Dataset for logs and traces (e.g. "events")
+AXIOM_DATASET_EVENTS=
+# Dataset for metrics (e.g. "metrics")
+AXIOM_DATASET_METRICS=
# [optional] Websocket configuration for https://pusher.com if you will like to frontend to update in real time
@@ -58,3 +69,7 @@ PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_CLUSTER=
+
+# Cloudflare Turnstile secret key for validating captcha tokens on the /v1/messages/search route
+# Get your secret key at https://developers.cloudflare.com/turnstile/get-started/
+CLOUDFLARE_TURNSTILE_SECRET_KEY=
diff --git a/api/Dockerfile b/api/Dockerfile
index 34d82403..6e6423b1 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang as builder
+FROM golang AS builder
ARG GIT_COMMIT
ENV GIT_COMMIT=$GIT_COMMIT
@@ -21,7 +21,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=$GI
FROM alpine:latest
-RUN addgroup -S http-sms && adduser -S http-sms -G http-sms
+RUN apk add --no-cache curl && addgroup -S http-sms && adduser -S http-sms -G http-sms
USER http-sms
WORKDIR /home/http-sms
diff --git a/api/cmd/fcm/main.go b/api/cmd/fcm/main.go
index 4906142b..470397cf 100644
--- a/api/cmd/fcm/main.go
+++ b/api/cmd/fcm/main.go
@@ -18,7 +18,7 @@ func main() {
}
container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), "")
- client := container.FirebaseMessagingClient()
+ client := container.FCMClient()
result, err := client.Send(context.Background(), &messaging.Message{
Data: map[string]string{
diff --git a/api/docs/docs.go b/api/docs/docs.go
index 21f3a971..dadff723 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -1,5 +1,4 @@
-// Package docs GENERATED BY SWAG; DO NOT EDIT
-// This file was generated by swaggo/swag
+// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
@@ -145,13 +144,51 @@ const docTemplate = `{
}
},
"/bulk-messages": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Fetches the last 10 bulk message order summaries for the authenticated user showing counts per status.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "BulkSMS"
+ ],
+ "summary": "List bulk message orders",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.BulkMessagesResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Sends bulk SMS messages to multiple users from a CSV or Excel file.",
+ "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).",
"consumes": [
"multipart/form-data"
],
@@ -165,7 +202,7 @@ const docTemplate = `{
"parameters": [
{
"type": "file",
- "description": "The Excel or CSV file formatted according to the templates",
+ "description": "The Excel or CSV file containing the messages to be sent.",
"name": "document",
"in": "formData",
"required": true
@@ -710,53 +747,6 @@ const docTemplate = `{
}
}
},
- "/lemonsqueezy/event": {
- "post": {
- "description": "Publish a lemonsqueezy event to the registered listeners",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "Lemonsqueezy"
- ],
- "summary": "Consume a lemonsqueezy event",
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
"/message-threads": {
"get": {
"security": [
@@ -1419,7 +1409,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
- "description": "Add a new SMS message to be sent by the android phone",
+ "description": "Add a new SMS message to be sent by your Android phone",
"consumes": [
"application/json"
],
@@ -1429,10 +1419,10 @@ const docTemplate = `{
"tags": [
"Messages"
],
- "summary": "Send a new SMS message",
+ "summary": "Send an SMS message",
"parameters": [
{
- "description": "PostSend message request payload",
+ "description": "Send message request payload",
"name": "payload",
"in": "body",
"required": true,
@@ -1476,6 +1466,72 @@ const docTemplate = `{
}
},
"/messages/{messageID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get a message from the database by the message ID.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Get a message from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
"delete": {
"security": [
{
@@ -1741,6 +1797,12 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
+ "402": {
+ "description": "Payment Required",
+ "schema": {
+ "$ref": "#/definitions/responses.PaymentRequired"
+ }
+ },
"422": {
"description": "Unprocessable Entity",
"schema": {
@@ -2161,35 +2223,26 @@ const docTemplate = `{
}
}
},
- "/users/me": {
+ "/send-schedules": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Get details of the currently authenticated user",
- "consumes": [
- "application/json"
- ],
+ "description": "List all send schedules owned by the authenticated user.",
"produces": [
"application/json"
],
"tags": [
- "Users"
+ "SendSchedules"
],
- "summary": "Get current user",
+ "summary": "List send schedules",
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/responses.UserResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
+ "$ref": "#/definitions/responses.MessageSendSchedulesResponse"
}
},
"401": {
@@ -2198,12 +2251,6 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
"500": {
"description": "Internal Server Error",
"schema": {
@@ -2212,13 +2259,13 @@ const docTemplate = `{
}
}
},
- "put": {
+ "post": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Updates the details of the currently authenticated user",
+ "description": "Create a new send schedule for the authenticated user.",
"consumes": [
"application/json"
],
@@ -2226,25 +2273,25 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "Users"
+ "SendSchedules"
],
- "summary": "Update a user",
+ "summary": "Create send schedule",
"parameters": [
{
- "description": "Payload of user details to update",
+ "description": "Payload of new send schedule.",
"name": "payload",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/requests.UserUpdate"
+ "$ref": "#/definitions/requests.MessageSendScheduleStore"
}
}
],
"responses": {
- "200": {
- "description": "OK",
+ "201": {
+ "description": "Created",
"schema": {
- "$ref": "#/definitions/responses.PhoneResponse"
+ "$ref": "#/definitions/responses.MessageSendScheduleResponse"
}
},
"400": {
@@ -2259,6 +2306,12 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
+ "402": {
+ "description": "Payment Required",
+ "schema": {
+ "$ref": "#/definitions/responses.PaymentRequired"
+ }
+ },
"422": {
"description": "Unprocessable Entity",
"schema": {
@@ -2272,14 +2325,16 @@ const docTemplate = `{
}
}
}
- },
- "delete": {
+ }
+ },
+ "/send-schedules/{scheduleID}": {
+ "put": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Deletes the currently authenticated user together with all their data.",
+ "description": "Update a send schedule owned by the authenticated user.",
"consumes": [
"application/json"
],
@@ -2287,14 +2342,38 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "Users"
+ "SendSchedules"
+ ],
+ "summary": "Update send schedule",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Schedule ID",
+ "name": "scheduleID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of updated send schedule.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageSendScheduleStore"
+ }
+ }
],
- "summary": "Delete a user",
"responses": {
- "201": {
- "description": "Created",
+ "200": {
+ "description": "OK",
"schema": {
- "$ref": "#/definitions/responses.NoContent"
+ "$ref": "#/definitions/responses.MessageSendScheduleResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
}
},
"401": {
@@ -2303,6 +2382,18 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
"500": {
"description": "Internal Server Error",
"schema": {
@@ -2310,29 +2401,33 @@ const docTemplate = `{
}
}
}
- }
- },
- "/users/subscription": {
+ },
"delete": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Cancel the subscription of the authenticated user.",
+ "description": "Delete a send schedule owned by the authenticated user.",
"produces": [
"application/json"
],
"tags": [
- "Users"
+ "SendSchedules"
+ ],
+ "summary": "Delete send schedule",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Schedule ID",
+ "name": "scheduleID",
+ "in": "path",
+ "required": true
+ }
],
- "summary": "Cancel the user's subscription",
"responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
+ "204": {
+ "description": "No Content"
},
"400": {
"description": "Bad Request",
@@ -2346,10 +2441,10 @@ const docTemplate = `{
"$ref": "#/definitions/responses.Unauthorized"
}
},
- "422": {
- "description": "Unprocessable Entity",
+ "404": {
+ "description": "Not Found",
"schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
+ "$ref": "#/definitions/responses.NotFound"
}
},
"500": {
@@ -2361,16 +2456,216 @@ const docTemplate = `{
}
}
},
- "/users/subscription-update-url": {
+ "/users/me": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Fetches the subscription URL of the authenticated user.",
- "produces": [
- "application/json"
+ "description": "Get details of the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get current user",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Updates the details of the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Update a user",
+ "parameters": [
+ {
+ "description": "Payload of user details to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.UserUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Deletes the currently authenticated user together with all their data.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Delete a user",
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Cancel the subscription of the authenticated user.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Cancel the user's subscription",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription-update-url": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Fetches the subscription URL of the authenticated user.",
+ "produces": [
+ "application/json"
],
"tags": [
"Users"
@@ -2410,6 +2705,128 @@ const docTemplate = `{
}
}
},
+ "/users/subscription/invoices/{subscriptionInvoiceID}": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/pdf"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Generate a subscription payment invoice",
+ "parameters": [
+ {
+ "description": "Generate subscription payment invoice parameters",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.UserPaymentInvoice"
+ }
+ },
+ {
+ "type": "string",
+ "description": "ID of the subscription invoice to generate the PDF for",
+ "name": "subscriptionInvoiceID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription/payments": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get the last 10 subscription payments.",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
"/users/{userID}/api-keys": {
"delete": {
"security": [
@@ -2543,6 +2960,68 @@ const docTemplate = `{
}
}
},
+ "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": {
+ "get": {
+ "description": "Download an MMS attachment by its path components",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "Attachments"
+ ],
+ "summary": "Download a message attachment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Message ID",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Attachment index",
+ "name": "attachmentIndex",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Filename with extension",
+ "name": "filename",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
"/webhooks": {
"get": {
"security": [
@@ -2864,6 +3343,53 @@ const docTemplate = `{
}
}
},
+ "entities.BulkMessage": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "delivered_count",
+ "failed_count",
+ "pending_count",
+ "request_id",
+ "scheduled_count",
+ "sent_count",
+ "total"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "delivered_count": {
+ "type": "integer",
+ "example": 25
+ },
+ "failed_count": {
+ "type": "integer",
+ "example": 5
+ },
+ "pending_count": {
+ "type": "integer",
+ "example": 30
+ },
+ "request_id": {
+ "type": "string",
+ "example": "bulk-32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "scheduled_count": {
+ "type": "integer",
+ "example": 50
+ },
+ "sent_count": {
+ "type": "integer",
+ "example": 40
+ },
+ "total": {
+ "type": "integer",
+ "example": 150
+ }
+ }
+ },
"entities.Discord": {
"type": "object",
"required": [
@@ -2946,28 +3472,17 @@ const docTemplate = `{
"entities.Message": {
"type": "object",
"required": [
- "can_be_polled",
+ "attachments",
"contact",
"content",
"created_at",
- "delivered_at",
"encrypted",
- "expired_at",
- "failed_at",
- "failure_reason",
"id",
- "last_attempted_at",
"max_send_attempts",
"order_timestamp",
"owner",
- "received_at",
- "request_id",
"request_received_at",
- "scheduled_at",
- "scheduled_send_time",
"send_attempt_count",
- "send_time",
- "sent_at",
"sim",
"status",
"type",
@@ -2975,9 +3490,15 @@ const docTemplate = `{
"user_id"
],
"properties": {
- "can_be_polled": {
- "type": "boolean",
- "example": false
+ "attachments": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
},
"contact": {
"type": "string",
@@ -3060,22 +3581,64 @@ const docTemplate = `{
"type": "integer",
"example": 133414
},
- "sent_at": {
+ "sent_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "sim": {
+ "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
+ "example": "DEFAULT"
+ },
+ "status": {
+ "type": "string",
+ "example": "pending"
+ },
+ "type": {
+ "type": "string",
+ "example": "mobile-terminated"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
"type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.MessageSendSchedule": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "id",
+ "name",
+ "timezone",
+ "updated_at",
+ "user_id",
+ "windows"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
},
- "sim": {
- "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
+ "id": {
"type": "string",
- "example": "DEFAULT"
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
},
- "status": {
+ "name": {
"type": "string",
- "example": "pending"
+ "example": "Business Hours"
},
- "type": {
+ "timezone": {
"type": "string",
- "example": "mobile-terminated"
+ "example": "Europe/Tallinn"
},
"updated_at": {
"type": "string",
@@ -3084,6 +3647,34 @@ const docTemplate = `{
"user_id": {
"type": "string",
"example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ },
+ "windows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.MessageSendScheduleWindow"
+ }
+ }
+ }
+ },
+ "entities.MessageSendScheduleWindow": {
+ "type": "object",
+ "required": [
+ "day_of_week",
+ "end_minute",
+ "start_minute"
+ ],
+ "properties": {
+ "day_of_week": {
+ "type": "integer",
+ "example": 1
+ },
+ "end_minute": {
+ "type": "integer",
+ "example": 1020
+ },
+ "start_minute": {
+ "type": "integer",
+ "example": 540
}
}
},
@@ -3158,12 +3749,10 @@ const docTemplate = `{
"type": "object",
"required": [
"created_at",
- "fcm_token",
"id",
"max_send_attempts",
"message_expiration_seconds",
"messages_per_minute",
- "missed_call_auto_reply",
"phone_number",
"sim",
"updated_at",
@@ -3191,6 +3780,10 @@ const docTemplate = `{
"description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.",
"type": "integer"
},
+ "message_send_schedule_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
"messages_per_minute": {
"type": "integer",
"example": 1
@@ -3204,8 +3797,7 @@ const docTemplate = `{
"example": "+18005550199"
},
"sim": {
- "description": "SIM card that received the message",
- "type": "string"
+ "$ref": "#/definitions/entities.SIM"
},
"updated_at": {
"type": "string",
@@ -3281,10 +3873,49 @@ const docTemplate = `{
}
}
},
+ "entities.SIM": {
+ "type": "string",
+ "enum": [
+ "SIM1",
+ "SIM2"
+ ],
+ "x-enum-varnames": [
+ "SIM1",
+ "SIM2"
+ ]
+ },
+ "entities.SubscriptionName": {
+ "type": "string",
+ "enum": [
+ "free",
+ "pro-monthly",
+ "pro-yearly",
+ "ultra-monthly",
+ "ultra-yearly",
+ "pro-lifetime",
+ "20k-monthly",
+ "100k-monthly",
+ "50k-monthly",
+ "200k-monthly",
+ "20k-yearly"
+ ],
+ "x-enum-varnames": [
+ "SubscriptionNameFree",
+ "SubscriptionNameProMonthly",
+ "SubscriptionNameProYearly",
+ "SubscriptionNameUltraMonthly",
+ "SubscriptionNameUltraYearly",
+ "SubscriptionNameProLifetime",
+ "SubscriptionName20KMonthly",
+ "SubscriptionName100KMonthly",
+ "SubscriptionName50KMonthly",
+ "SubscriptionName200KMonthly",
+ "SubscriptionName20KYearly"
+ ]
+ },
"entities.User": {
"type": "object",
"required": [
- "active_phone_id",
"api_key",
"created_at",
"email",
@@ -3293,11 +3924,8 @@ const docTemplate = `{
"notification_message_status_enabled",
"notification_newsletter_enabled",
"notification_webhook_enabled",
- "subscription_ends_at",
"subscription_id",
"subscription_name",
- "subscription_renews_at",
- "subscription_status",
"timezone",
"updated_at"
],
@@ -3347,7 +3975,11 @@ const docTemplate = `{
"example": "8f9c71b8-b84e-4417-8408-a62274f65a08"
},
"subscription_name": {
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SubscriptionName"
+ }
+ ],
"example": "free"
},
"subscription_renews_at": {
@@ -3482,15 +4114,46 @@ const docTemplate = `{
}
}
},
+ "requests.MessageAttachment": {
+ "type": "object",
+ "required": [
+ "content",
+ "content_type",
+ "name"
+ ],
+ "properties": {
+ "content": {
+ "description": "Content is the base64-encoded attachment data",
+ "type": "string",
+ "example": "base64data..."
+ },
+ "content_type": {
+ "description": "ContentType is the MIME type of the attachment",
+ "type": "string",
+ "example": "image/jpeg"
+ },
+ "name": {
+ "description": "Name is the original filename of the attachment",
+ "type": "string",
+ "example": "photo.jpg"
+ }
+ }
+ },
"requests.MessageBulkSend": {
"type": "object",
"required": [
"content",
- "encrypted",
"from",
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
@@ -3583,6 +4246,13 @@ const docTemplate = `{
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments is the list of MMS attachments received with the message",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageAttachment"
+ }
+ },
"content": {
"type": "string",
"example": "This is a sample text message received on a phone"
@@ -3598,7 +4268,11 @@ const docTemplate = `{
},
"sim": {
"description": "SIM card that received the message",
- "type": "string",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
"example": "SIM1"
},
"timestamp": {
@@ -3620,6 +4294,17 @@ const docTemplate = `{
"to"
],
"properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
"content": {
"type": "string",
"example": "This is a sample text message"
@@ -3639,9 +4324,9 @@ const docTemplate = `{
"example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
},
"send_at": {
- "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone.",
+ "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.",
"type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
+ "example": "2025-12-19T16:39:57-08:00"
},
"to": {
"type": "string",
@@ -3649,6 +4334,47 @@ const docTemplate = `{
}
}
},
+ "requests.MessageSendScheduleStore": {
+ "type": "object",
+ "required": [
+ "name",
+ "timezone",
+ "windows"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "windows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageSendScheduleWindow"
+ }
+ }
+ }
+ },
+ "requests.MessageSendScheduleWindow": {
+ "type": "object",
+ "required": [
+ "day_of_week",
+ "end_minute",
+ "start_minute"
+ ],
+ "properties": {
+ "day_of_week": {
+ "type": "integer"
+ },
+ "end_minute": {
+ "type": "integer"
+ },
+ "start_minute": {
+ "type": "integer"
+ }
+ }
+ },
"requests.MessageThreadUpdate": {
"type": "object",
"required": [
@@ -3722,6 +4448,10 @@ const docTemplate = `{
"type": "integer",
"example": 12345
},
+ "message_send_schedule_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
"messages_per_minute": {
"type": "integer",
"example": 1
@@ -3768,6 +4498,48 @@ const docTemplate = `{
}
}
},
+ "requests.UserPaymentInvoice": {
+ "type": "object",
+ "required": [
+ "address",
+ "city",
+ "country",
+ "name",
+ "notes",
+ "state",
+ "zip_code"
+ ],
+ "properties": {
+ "address": {
+ "type": "string",
+ "example": "221B Baker Street, London"
+ },
+ "city": {
+ "type": "string",
+ "example": "Los Angeles"
+ },
+ "country": {
+ "type": "string",
+ "example": "US"
+ },
+ "name": {
+ "type": "string",
+ "example": "Acme Corp"
+ },
+ "notes": {
+ "type": "string",
+ "example": "Thank you for your business!"
+ },
+ "state": {
+ "type": "string",
+ "example": "CA"
+ },
+ "zip_code": {
+ "type": "string",
+ "example": "9800"
+ }
+ }
+ },
"requests.UserUpdate": {
"type": "object",
"required": [
@@ -3918,6 +4690,30 @@ const docTemplate = `{
}
}
},
+ "responses.BulkMessagesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.BulkMessage"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
"responses.DiscordResponse": {
"type": "object",
"required": [
@@ -4046,6 +4842,51 @@ const docTemplate = `{
}
}
},
+ "responses.MessageSendScheduleResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.MessageSendSchedule"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.MessageSendSchedulesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.MessageSendSchedule"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
"responses.MessageThreadsResponse": {
"type": "object",
"required": [
@@ -4149,6 +4990,23 @@ const docTemplate = `{
}
}
},
+ "responses.PaymentRequired": {
+ "type": "object",
+ "required": [
+ "message",
+ "status"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You have reached the maximum number of allowed resources. Please upgrade your plan."
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
"responses.PhoneAPIKeyResponse": {
"type": "object",
"required": [
@@ -4309,6 +5167,156 @@ const docTemplate = `{
}
}
},
+ "responses.UserSubscriptionPaymentsResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "attributes",
+ "id",
+ "type"
+ ],
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "required": [
+ "billing_reason",
+ "card_brand",
+ "card_last_four",
+ "created_at",
+ "currency",
+ "currency_rate",
+ "discount_total",
+ "discount_total_formatted",
+ "discount_total_usd",
+ "refunded",
+ "refunded_amount",
+ "refunded_amount_formatted",
+ "refunded_amount_usd",
+ "refunded_at",
+ "status",
+ "status_formatted",
+ "subtotal",
+ "subtotal_formatted",
+ "subtotal_usd",
+ "tax",
+ "tax_formatted",
+ "tax_inclusive",
+ "tax_usd",
+ "total",
+ "total_formatted",
+ "total_usd",
+ "updated_at"
+ ],
+ "properties": {
+ "billing_reason": {
+ "type": "string"
+ },
+ "card_brand": {
+ "type": "string"
+ },
+ "card_last_four": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "currency": {
+ "type": "string"
+ },
+ "currency_rate": {
+ "type": "string"
+ },
+ "discount_total": {
+ "type": "integer"
+ },
+ "discount_total_formatted": {
+ "type": "string"
+ },
+ "discount_total_usd": {
+ "type": "integer"
+ },
+ "refunded": {
+ "type": "boolean"
+ },
+ "refunded_amount": {
+ "type": "integer"
+ },
+ "refunded_amount_formatted": {
+ "type": "string"
+ },
+ "refunded_amount_usd": {
+ "type": "integer"
+ },
+ "refunded_at": {},
+ "status": {
+ "type": "string"
+ },
+ "status_formatted": {
+ "type": "string"
+ },
+ "subtotal": {
+ "type": "integer"
+ },
+ "subtotal_formatted": {
+ "type": "string"
+ },
+ "subtotal_usd": {
+ "type": "integer"
+ },
+ "tax": {
+ "type": "integer"
+ },
+ "tax_formatted": {
+ "type": "string"
+ },
+ "tax_inclusive": {
+ "type": "boolean"
+ },
+ "tax_usd": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "total_formatted": {
+ "type": "string"
+ },
+ "total_usd": {
+ "type": "integer"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
+ "id": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
"responses.WebhookResponse": {
"type": "object",
"required": [
@@ -4374,6 +5382,8 @@ var SwaggerInfo = &swag.Spec{
Description: "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
}
func init() {
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index 5cef4eb0..69e9d363 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -1,3942 +1,5370 @@
{
- "schemes": ["https"],
- "swagger": "2.0",
- "info": {
- "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.",
- "title": "httpSMS API Reference",
- "contact": {
- "name": "support@httpsms.com",
- "email": "support@httpsms.com"
- },
- "license": {
- "name": "AGPL-3.0",
- "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE"
- },
- "version": "1.0"
- },
- "host": "api.httpsms.com",
- "basePath": "/v1",
- "paths": {
- "/billing/usage": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get the summary of sent and received messages for a user in the current month",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Billing"],
- "summary": "Get Billing Usage.",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.BillingUsageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/billing/usage-history": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Billing"],
- "summary": "Get billing usage history.",
- "parameters": [
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of heartbeats to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "maximum": 100,
- "minimum": 1,
- "type": "integer",
- "description": "number of heartbeats to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.BillingUsagesResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/bulk-messages": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Sends bulk SMS messages to multiple users from a CSV or Excel file.",
- "consumes": ["multipart/form-data"],
- "produces": ["application/json"],
- "tags": ["BulkSMS"],
- "summary": "Store bulk SMS file",
- "parameters": [
- {
- "type": "file",
- "description": "The Excel or CSV file formatted according to the templates",
- "name": "document",
- "in": "formData",
- "required": true
- }
- ],
- "responses": {
- "202": {
- "description": "Accepted",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/discord-integrations": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get the discord integrations of a user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["DiscordIntegration"],
- "summary": "Get discord integrations of a user",
- "parameters": [
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of discord integrations to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter discord integrations containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of discord integrations to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.DiscordsResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Store a discord integration for the authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["DiscordIntegration"],
- "summary": "Store discord integration",
- "parameters": [
- {
- "description": "Payload of the discord integration request",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.DiscordStore"
- }
- }
- ],
- "responses": {
- "201": {
- "description": "Created",
- "schema": {
- "$ref": "#/definitions/responses.DiscordResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/discord-integrations/{discordID}": {
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Update a discord integration for the currently authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["DiscordIntegration"],
- "summary": "Update a discord integration",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the discord integration",
- "name": "discordID",
- "in": "path",
- "required": true
- },
- {
- "description": "Payload of discord integration to update",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.DiscordUpdate"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.DiscordResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a discord integration for a user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Webhooks"],
- "summary": "Delete discord integration",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the discord integration",
- "name": "discordID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/discord/event": {
- "post": {
- "description": "Publish a discord event to the registered listeners",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Discord"],
- "summary": "Consume a discord event",
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/heartbeats": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Heartbeats"],
- "summary": "Get heartbeats of an owner phone number",
- "parameters": [
- {
- "type": "string",
- "default": "+18005550199",
- "description": "the owner's phone number",
- "name": "owner",
- "in": "query",
- "required": true
- },
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of heartbeats to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of heartbeats to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.HeartbeatsResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Store the heartbeat to make notify that a phone number is still active",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Heartbeats"],
- "summary": "Register heartbeat of an owner phone number",
- "parameters": [
- {
- "description": "Payload of the heartbeat request",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.HeartbeatStore"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.HeartbeatResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/integration/3cx/messages": {
- "post": {
- "description": "Sends an SMS message from the 3CX platform",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["3CXIntegration"],
- "summary": "Sends a 3CX SMS message",
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/lemonsqueezy/event": {
- "post": {
- "description": "Publish a lemonsqueezy event to the registered listeners",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Lemonsqueezy"],
- "summary": "Consume a lemonsqueezy event",
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/message-threads": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["MessageThreads"],
- "summary": "Get message threads for a phone number",
- "parameters": [
- {
- "type": "string",
- "default": "+18005550199",
- "description": "owner phone number",
- "name": "owner",
- "in": "query",
- "required": true
- },
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of messages to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter message threads containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of messages to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageThreadsResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/message-threads/{messageThreadID}": {
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Updates the details of a message thread",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["MessageThreads"],
- "summary": "Update a message thread",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the message thread",
- "name": "messageThreadID",
- "in": "path",
- "required": true
- },
- {
- "description": "Payload of message thread details to update",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageThreadUpdate"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a message thread from the database and also deletes all the messages in the thread.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["MessageThreads"],
- "summary": "Delete a message thread from the database.",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the message thread",
- "name": "messageThreadID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Get messages which are sent between 2 phone numbers",
- "parameters": [
- {
- "type": "string",
- "default": "+18005550199",
- "description": "the owner's phone number",
- "name": "owner",
- "in": "query",
- "required": true
- },
- {
- "type": "string",
- "default": "+18005550100",
- "description": "the contact's phone number",
- "name": "contact",
- "in": "query",
- "required": true
- },
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of messages to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter messages containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of messages to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessagesResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/bulk-send": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Add bulk SMS messages to be sent by the android phone",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Send bulk SMS messages",
- "parameters": [
- {
- "description": "Bulk send message request payload",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageBulkSend"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/responses.MessagesResponse"
- }
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/calls/missed": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Register a missed call event on the mobile phone",
- "parameters": [
- {
- "description": "Payload of the missed call event.",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageCallMissed"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/outstanding": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get an outstanding message to be sent by an android phone",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Get an outstanding message",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703cb",
- "description": "The ID of the message",
- "name": "message_id",
- "in": "query",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/receive": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Add a new message received from a mobile phone",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Receive a new SMS message from a mobile phone",
- "parameters": [
- {
- "description": "Received message request payload",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageReceive"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/search": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "This returns the list of all messages based on the filter criteria including missed calls",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Search all messages of a user",
- "parameters": [
- {
- "type": "string",
- "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/",
- "name": "token",
- "in": "header",
- "required": true
- },
- {
- "type": "string",
- "default": "+18005550199,+18005550100",
- "description": "the owner's phone numbers",
- "name": "owners",
- "in": "query",
- "required": true
- },
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of messages to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter messages containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 200,
- "minimum": 1,
- "type": "integer",
- "description": "number of messages to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessagesResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/send": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Add a new SMS message to be sent by the android phone",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Send a new SMS message",
- "parameters": [
- {
- "description": "PostSend message request payload",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageSend"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/{messageID}": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a message from the database and removes the message content from the list of threads.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Delete a message from the database.",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the message",
- "name": "messageID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/messages/{messageID}/events": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Messages"],
- "summary": "Upsert an event for a message on the mobile phone",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the message",
- "name": "messageID",
- "in": "path",
- "required": true
- },
- {
- "description": "Payload of the event emitted.",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.MessageEvent"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.MessageResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phone-api-keys": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get list phone API keys which a user has registered on the httpSMS application",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["PhoneAPIKeys"],
- "summary": "Get the phone API keys of a user",
- "parameters": [
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of phone api keys to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter phone api keys with name containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 100,
- "minimum": 1,
- "type": "integer",
- "description": "number of phone api keys to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneAPIKeysResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["PhoneAPIKeys"],
- "summary": "Store phone API key",
- "parameters": [
- {
- "description": "Payload of new phone API key.",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneAPIKeyResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phone-api-keys/{phoneAPIKeyID}": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["PhoneAPIKeys"],
- "summary": "Delete a phone API key from the database.",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the phone API key",
- "name": "phoneAPIKeyID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["PhoneAPIKeys"],
- "summary": "Remove the association of a phone from the phone API key.",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the phone API key",
- "name": "phoneAPIKeyID",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the phone",
- "name": "phoneID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/responses.NotFound"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phones": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get list of phones which a user has registered on the http sms application",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Phones"],
- "summary": "Get phones of a user",
- "parameters": [
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of heartbeats to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter phones containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of phones to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhonesResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Phones"],
- "summary": "Upsert Phone",
- "parameters": [
- {
- "description": "Payload of new phone number.",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.PhoneUpsert"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phones/fcm-token": {
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Phones"],
- "summary": "Upserts the FCM token of a phone",
- "parameters": [
- {
- "description": "Payload of new FCM token.",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.PhoneFCMToken"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/phones/{phoneID}": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a phone that has been sored in the database",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Phones"],
- "summary": "Delete Phone",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the phone",
- "name": "phoneID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/users/me": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get details of the currently authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Get current user",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.UserResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Updates the details of the currently authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Update a user",
- "parameters": [
- {
- "description": "Payload of user details to update",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.UserUpdate"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.PhoneResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Deletes the currently authenticated user together with all their data.",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Delete a user",
- "responses": {
- "201": {
- "description": "Created",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/users/subscription": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Cancel the subscription of the authenticated user.",
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Cancel the user's subscription",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/users/subscription-update-url": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Fetches the subscription URL of the authenticated user.",
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Currently authenticated user subscription update URL",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.OkString"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/users/{userID}/api-keys": {
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Rotate the user's API key in case the current API Key is compromised",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Rotate the user's API Key",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the user to update",
- "name": "userID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.UserResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/users/{userID}/notifications": {
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Update the email notification settings for a user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Users"],
- "summary": "Update notification settings",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the user to update",
- "name": "userID",
- "in": "path",
- "required": true
- },
- {
- "description": "User notification details to update",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.UserNotificationUpdate"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.UserResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/webhooks": {
- "get": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Get the webhooks of a user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Webhooks"],
- "summary": "Get webhooks of a user",
- "parameters": [
- {
- "minimum": 0,
- "type": "integer",
- "description": "number of webhooks to skip",
- "name": "skip",
- "in": "query"
- },
- {
- "type": "string",
- "description": "filter webhooks containing query",
- "name": "query",
- "in": "query"
- },
- {
- "maximum": 20,
- "minimum": 1,
- "type": "integer",
- "description": "number of webhooks to return",
- "name": "limit",
- "in": "query"
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.WebhooksResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Store a webhook for the authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Webhooks"],
- "summary": "Store a webhook",
- "parameters": [
- {
- "description": "Payload of the webhook request",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.WebhookStore"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.WebhookResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- },
- "/webhooks/{webhookID}": {
- "put": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Update a webhook for the currently authenticated user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Webhooks"],
- "summary": "Update a webhook",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the webhook",
- "name": "webhookID",
- "in": "path",
- "required": true
- },
- {
- "description": "Payload of webhook details to update",
- "name": "payload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/requests.WebhookUpdate"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/responses.WebhookResponse"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- },
- "delete": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Delete a webhook for a user",
- "consumes": ["application/json"],
- "produces": ["application/json"],
- "tags": ["Webhooks"],
- "summary": "Delete webhook",
- "parameters": [
- {
- "type": "string",
- "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
- "description": "ID of the webhook",
- "name": "webhookID",
- "in": "path",
- "required": true
- }
- ],
- "responses": {
- "204": {
- "description": "No Content",
- "schema": {
- "$ref": "#/definitions/responses.NoContent"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/responses.BadRequest"
- }
- },
- "401": {
- "description": "Unauthorized",
- "schema": {
- "$ref": "#/definitions/responses.Unauthorized"
- }
- },
- "422": {
- "description": "Unprocessable Entity",
- "schema": {
- "$ref": "#/definitions/responses.UnprocessableEntity"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/responses.InternalServerError"
- }
- }
- }
- }
- }
- },
- "definitions": {
- "entities.BillingUsage": {
- "type": "object",
- "required": [
- "created_at",
- "end_timestamp",
- "id",
- "received_messages",
- "sent_messages",
- "start_timestamp",
- "total_cost",
- "updated_at",
- "user_id"
- ],
- "properties": {
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "end_timestamp": {
- "type": "string",
- "example": "2022-01-31T23:59:59+00:00"
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "received_messages": {
- "type": "integer",
- "example": 465
- },
- "sent_messages": {
- "type": "integer",
- "example": 321
- },
- "start_timestamp": {
- "type": "string",
- "example": "2022-01-01T00:00:00+00:00"
- },
- "total_cost": {
- "type": "integer",
- "example": 0
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.Discord": {
- "type": "object",
- "required": [
- "created_at",
- "id",
- "incoming_channel_id",
- "name",
- "server_id",
- "updated_at",
- "user_id"
- ],
- "properties": {
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "incoming_channel_id": {
- "type": "string",
- "example": "1095780203256627291"
- },
- "name": {
- "type": "string",
- "example": "Game Server"
- },
- "server_id": {
- "type": "string",
- "example": "1095778291488653372"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.Heartbeat": {
- "type": "object",
- "required": [
- "charging",
- "id",
- "owner",
- "timestamp",
- "user_id",
- "version"
- ],
- "properties": {
- "charging": {
- "type": "boolean",
- "example": true
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "owner": {
- "type": "string",
- "example": "+18005550199"
- },
- "timestamp": {
- "type": "string",
- "example": "2022-06-05T14:26:01.520828+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- },
- "version": {
- "type": "string",
- "example": "344c10f"
- }
- }
- },
- "entities.Message": {
- "type": "object",
- "required": [
- "can_be_polled",
- "contact",
- "content",
- "created_at",
- "delivered_at",
- "encrypted",
- "expired_at",
- "failed_at",
- "failure_reason",
- "id",
- "last_attempted_at",
- "max_send_attempts",
- "order_timestamp",
- "owner",
- "received_at",
- "request_id",
- "request_received_at",
- "scheduled_at",
- "scheduled_send_time",
- "send_attempt_count",
- "send_time",
- "sent_at",
- "sim",
- "status",
- "type",
- "updated_at",
- "user_id"
- ],
- "properties": {
- "can_be_polled": {
- "type": "boolean",
- "example": false
- },
+ "schemes": [
+ "https"
+ ],
+ "swagger": "2.0",
+ "info": {
+ "description": "Use your Android phone to send and receive SMS messages via a simple programmable API with end-to-end encryption.",
+ "title": "httpSMS API Reference",
"contact": {
- "type": "string",
- "example": "+18005550100"
- },
- "content": {
- "type": "string",
- "example": "This is a sample text message"
- },
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "delivered_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "encrypted": {
- "type": "boolean",
- "example": false
- },
- "expired_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "failed_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "failure_reason": {
- "type": "string",
- "example": "UNKNOWN"
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "last_attempted_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "max_send_attempts": {
- "type": "integer",
- "example": 1
- },
- "order_timestamp": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "owner": {
- "type": "string",
- "example": "+18005550199"
- },
- "received_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "request_id": {
- "type": "string",
- "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
- },
- "request_received_at": {
- "type": "string",
- "example": "2022-06-05T14:26:01.520828+03:00"
- },
- "scheduled_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "scheduled_send_time": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "send_attempt_count": {
- "type": "integer",
- "example": 0
- },
- "send_time": {
- "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message",
- "type": "integer",
- "example": 133414
- },
- "sent_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "sim": {
- "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
- "type": "string",
- "example": "DEFAULT"
- },
- "status": {
- "type": "string",
- "example": "pending"
- },
- "type": {
- "type": "string",
- "example": "mobile-terminated"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.MessageThread": {
- "type": "object",
- "required": [
- "color",
- "contact",
- "created_at",
- "id",
- "is_archived",
- "last_message_content",
- "last_message_id",
- "order_timestamp",
- "owner",
- "status",
- "updated_at",
- "user_id"
- ],
- "properties": {
- "color": {
- "type": "string",
- "example": "indigo"
- },
- "contact": {
- "type": "string",
- "example": "+18005550100"
- },
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703ca"
+ "name": "support@httpsms.com",
+ "email": "support@httpsms.com"
},
- "is_archived": {
- "type": "boolean",
- "example": false
+ "license": {
+ "name": "AGPL-3.0",
+ "url": "https://raw.githubusercontent.com/NdoleStudio/http-sms-manager/main/LICENSE"
},
- "last_message_content": {
- "type": "string",
- "example": "This is a sample message content"
- },
- "last_message_id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703ca"
- },
- "order_timestamp": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "owner": {
- "type": "string",
- "example": "+18005550199"
- },
- "status": {
- "type": "string",
- "example": "PENDING"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.Phone": {
- "type": "object",
- "required": [
- "created_at",
- "fcm_token",
- "id",
- "max_send_attempts",
- "message_expiration_seconds",
- "messages_per_minute",
- "missed_call_auto_reply",
- "phone_number",
- "sim",
- "updated_at",
- "user_id"
- ],
- "properties": {
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "fcm_token": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "max_send_attempts": {
- "description": "MaxSendAttempts determines how many times to retry sending an SMS message",
- "type": "integer",
- "example": 2
- },
- "message_expiration_seconds": {
- "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.",
- "type": "integer"
- },
- "messages_per_minute": {
- "type": "integer",
- "example": 1
- },
- "missed_call_auto_reply": {
- "type": "string",
- "example": "This phone cannot receive calls. Please send an SMS instead."
- },
- "phone_number": {
- "type": "string",
- "example": "+18005550199"
- },
- "sim": {
- "description": "SIM card that received the message",
- "type": "string"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.PhoneAPIKey": {
- "type": "object",
- "required": [
- "api_key",
- "created_at",
- "id",
- "name",
- "phone_ids",
- "phone_numbers",
- "updated_at",
- "user_email",
- "user_id"
- ],
- "properties": {
- "api_key": {
- "type": "string",
- "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx"
- },
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "name": {
- "type": "string",
- "example": "Business Phone Key"
- },
- "phone_ids": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": [
- "32343a19-da5e-4b1b-a767-3298a73703cb",
- "32343a19-da5e-4b1b-a767-3298a73703cc"
- ]
- },
- "phone_numbers": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["+18005550199", "+18005550100"]
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "user_email": {
- "type": "string",
- "example": "user@gmail.com"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "entities.User": {
- "type": "object",
- "required": [
- "active_phone_id",
- "api_key",
- "created_at",
- "email",
- "id",
- "notification_heartbeat_enabled",
- "notification_message_status_enabled",
- "notification_newsletter_enabled",
- "notification_webhook_enabled",
- "subscription_ends_at",
- "subscription_id",
- "subscription_name",
- "subscription_renews_at",
- "subscription_status",
- "timezone",
- "updated_at"
- ],
- "properties": {
- "active_phone_id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "api_key": {
- "type": "string",
- "example": "x-api-key"
- },
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "email": {
- "type": "string",
- "example": "name@email.com"
- },
- "id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- },
- "notification_heartbeat_enabled": {
- "type": "boolean",
- "example": true
- },
- "notification_message_status_enabled": {
- "type": "boolean",
- "example": true
- },
- "notification_newsletter_enabled": {
- "type": "boolean",
- "example": true
- },
- "notification_webhook_enabled": {
- "type": "boolean",
- "example": true
- },
- "subscription_ends_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "subscription_id": {
- "type": "string",
- "example": "8f9c71b8-b84e-4417-8408-a62274f65a08"
- },
- "subscription_name": {
- "type": "string",
- "example": "free"
- },
- "subscription_renews_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "subscription_status": {
- "type": "string",
- "example": "on_trial"
- },
- "timezone": {
- "type": "string",
- "example": "Europe/Helsinki"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- }
- }
- },
- "entities.Webhook": {
- "type": "object",
- "required": [
- "created_at",
- "events",
- "id",
- "phone_numbers",
- "signing_key",
- "updated_at",
- "url",
- "user_id"
- ],
- "properties": {
- "created_at": {
- "type": "string",
- "example": "2022-06-05T14:26:02.302718+03:00"
- },
- "events": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["message.phone.received"]
- },
- "id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "phone_numbers": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["+18005550199", "+18005550100"]
- },
- "signing_key": {
- "type": "string",
- "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY"
- },
- "updated_at": {
- "type": "string",
- "example": "2022-06-05T14:26:10.303278+03:00"
- },
- "url": {
- "type": "string",
- "example": "https://example.com"
- },
- "user_id": {
- "type": "string",
- "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
- }
- }
- },
- "requests.DiscordStore": {
- "type": "object",
- "required": ["incoming_channel_id", "name", "server_id"],
- "properties": {
- "incoming_channel_id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "server_id": {
- "type": "string"
- }
- }
- },
- "requests.DiscordUpdate": {
- "type": "object",
- "required": ["incoming_channel_id", "name", "server_id"],
- "properties": {
- "incoming_channel_id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "server_id": {
- "type": "string"
- }
- }
- },
- "requests.HeartbeatStore": {
- "type": "object",
- "required": ["charging", "phone_numbers"],
- "properties": {
- "charging": {
- "type": "boolean"
- },
- "phone_numbers": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- },
- "requests.MessageBulkSend": {
- "type": "object",
- "required": ["content", "encrypted", "from", "to"],
- "properties": {
- "content": {
- "type": "string",
- "example": "This is a sample text message"
- },
- "encrypted": {
- "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
- "type": "boolean",
- "example": false
- },
- "from": {
- "type": "string",
- "example": "+18005550199"
- },
- "request_id": {
- "description": "RequestID is an optional parameter used to track a request from the client's perspective",
- "type": "string",
- "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
- },
- "to": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["+18005550100", "+18005550100"]
- }
- }
- },
- "requests.MessageCallMissed": {
- "type": "object",
- "required": ["from", "sim", "timestamp", "to"],
- "properties": {
- "from": {
- "type": "string",
- "example": "+18005550199"
- },
- "sim": {
- "type": "string",
- "example": "SIM1"
- },
- "timestamp": {
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "to": {
- "type": "string",
- "example": "+18005550100"
- }
- }
- },
- "requests.MessageEvent": {
- "type": "object",
- "required": ["event_name", "reason", "timestamp"],
- "properties": {
- "event_name": {
- "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone",
- "type": "string",
- "example": "SENT"
- },
- "reason": {
- "description": "Reason is the exact error message in case the event is an error",
- "type": "string"
- },
- "timestamp": {
- "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible",
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- }
- }
- },
- "requests.MessageReceive": {
- "type": "object",
- "required": ["content", "encrypted", "from", "sim", "timestamp", "to"],
- "properties": {
- "content": {
- "type": "string",
- "example": "This is a sample text message received on a phone"
- },
- "encrypted": {
- "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
- "type": "boolean",
- "example": false
- },
- "from": {
- "type": "string",
- "example": "+18005550199"
- },
- "sim": {
- "description": "SIM card that received the message",
- "type": "string",
- "example": "SIM1"
- },
- "timestamp": {
- "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible",
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "to": {
- "type": "string",
- "example": "+18005550100"
- }
- }
- },
- "requests.MessageSend": {
- "type": "object",
- "required": ["content", "from", "to"],
- "properties": {
- "content": {
- "type": "string",
- "example": "This is a sample text message"
- },
- "encrypted": {
- "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
- "type": "boolean",
- "example": false
- },
- "from": {
- "type": "string",
- "example": "+18005550199"
- },
- "request_id": {
- "description": "RequestID is an optional parameter used to track a request from the client's perspective",
- "type": "string",
- "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
- },
- "send_at": {
- "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone.",
- "type": "string",
- "example": "2022-06-05T14:26:09.527976+03:00"
- },
- "to": {
- "type": "string",
- "example": "+18005550100"
- }
- }
- },
- "requests.MessageThreadUpdate": {
- "type": "object",
- "required": ["is_archived"],
- "properties": {
- "is_archived": {
- "type": "boolean",
- "example": true
- }
- }
- },
- "requests.PhoneAPIKeyStoreRequest": {
- "type": "object",
- "required": ["name"],
- "properties": {
- "name": {
- "type": "string",
- "example": "My Phone API Key"
- }
- }
- },
- "requests.PhoneFCMToken": {
- "type": "object",
- "required": ["fcm_token", "phone_number", "sim"],
- "properties": {
- "fcm_token": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
- },
- "phone_number": {
- "type": "string",
- "example": "[+18005550199]"
- },
- "sim": {
- "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
- "type": "string",
- "example": "SIM1"
- }
- }
- },
- "requests.PhoneUpsert": {
- "type": "object",
- "required": [
- "fcm_token",
- "max_send_attempts",
- "message_expiration_seconds",
- "messages_per_minute",
- "missed_call_auto_reply",
- "phone_number",
- "sim"
- ],
- "properties": {
- "fcm_token": {
- "type": "string",
- "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
- },
- "max_send_attempts": {
- "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.",
- "type": "integer",
- "example": 2
- },
- "message_expiration_seconds": {
- "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.",
- "type": "integer",
- "example": 12345
- },
- "messages_per_minute": {
- "type": "integer",
- "example": 1
- },
- "missed_call_auto_reply": {
- "type": "string",
- "example": "e.g. This phone cannot receive calls. Please send an SMS instead."
- },
- "phone_number": {
- "type": "string",
- "example": "+18005550199"
- },
- "sim": {
- "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
- "type": "string",
- "example": "SIM1"
- }
- }
+ "version": "1.0"
},
- "requests.UserNotificationUpdate": {
- "type": "object",
- "required": [
- "heartbeat_enabled",
- "message_status_enabled",
- "newsletter_enabled",
- "webhook_enabled"
- ],
- "properties": {
- "heartbeat_enabled": {
- "type": "boolean",
- "example": true
- },
- "message_status_enabled": {
- "type": "boolean",
- "example": true
- },
- "newsletter_enabled": {
- "type": "boolean",
- "example": true
- },
- "webhook_enabled": {
- "type": "boolean",
- "example": true
- }
- }
- },
- "requests.UserUpdate": {
- "type": "object",
- "required": ["active_phone_id", "timezone"],
- "properties": {
- "active_phone_id": {
- "type": "string",
- "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
- },
- "timezone": {
- "type": "string",
- "example": "Europe/Helsinki"
- }
- }
- },
- "requests.WebhookStore": {
- "type": "object",
- "required": ["events", "phone_numbers", "signing_key", "url"],
- "properties": {
- "events": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "phone_numbers": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["+18005550100", "+18005550100"]
- },
- "signing_key": {
- "type": "string"
- },
- "url": {
- "type": "string"
- }
- }
- },
- "requests.WebhookUpdate": {
- "type": "object",
- "required": ["events", "phone_numbers", "signing_key", "url"],
- "properties": {
- "events": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "phone_numbers": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "example": ["+18005550100", "+18005550100"]
- },
- "signing_key": {
- "type": "string"
- },
- "url": {
- "type": "string"
- }
- }
- },
- "responses.BadRequest": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "string",
- "example": "The request body is not a valid JSON string"
- },
- "message": {
- "type": "string",
- "example": "The request isn't properly formed"
- },
- "status": {
- "type": "string",
- "example": "error"
- }
- }
- },
- "responses.BillingUsageResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.BillingUsage"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.BillingUsagesResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.BillingUsage"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.DiscordResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.Discord"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.DiscordsResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.Discord"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.HeartbeatResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.Heartbeat"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.HeartbeatsResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.Heartbeat"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.InternalServerError": {
- "type": "object",
- "required": ["message", "status"],
- "properties": {
- "message": {
- "type": "string",
- "example": "We ran into an internal error while handling the request."
- },
- "status": {
- "type": "string",
- "example": "error"
- }
- }
- },
- "responses.MessageResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.Message"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.MessageThreadsResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.MessageThread"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.MessagesResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.Message"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.NoContent": {
- "type": "object",
- "required": ["message", "status"],
- "properties": {
- "message": {
- "type": "string",
- "example": "action performed successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.NotFound": {
- "type": "object",
- "required": ["message", "status"],
- "properties": {
- "message": {
- "type": "string",
- "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]"
- },
- "status": {
- "type": "string",
- "example": "error"
- }
- }
- },
- "responses.OkString": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "string"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.PhoneAPIKeyResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.PhoneAPIKey"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.PhoneAPIKeysResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.PhoneAPIKey"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.PhoneResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.Phone"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.PhonesResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.Phone"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
- }
- }
- },
- "responses.Unauthorized": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "string",
- "example": "Make sure your API key is set in the [X-API-Key] header in the request"
- },
- "message": {
- "type": "string",
- "example": "You are not authorized to carry out this request."
- },
- "status": {
- "type": "string",
- "example": "error"
- }
- }
- },
- "responses.UnprocessableEntity": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "object",
- "additionalProperties": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- },
- "message": {
- "type": "string",
- "example": "validation errors while handling request"
- },
- "status": {
- "type": "string",
- "example": "error"
- }
- }
- },
- "responses.UserResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.User"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
+ "host": "api.httpsms.com",
+ "basePath": "/v1",
+ "paths": {
+ "/billing/usage": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get the summary of sent and received messages for a user in the current month",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Billing"
+ ],
+ "summary": "Get Billing Usage.",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.BillingUsageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/billing/usage-history": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get billing usage records of sent and received messages for a user in the past. It will be sorted by timestamp in descending order.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Billing"
+ ],
+ "summary": "Get billing usage history.",
+ "parameters": [
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of heartbeats to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of heartbeats to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.BillingUsagesResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/bulk-messages": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Fetches the last 10 bulk message order summaries for the authenticated user showing counts per status.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "BulkSMS"
+ ],
+ "summary": "List bulk message orders",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.BulkMessagesResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).",
+ "consumes": [
+ "multipart/form-data"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "BulkSMS"
+ ],
+ "summary": "Store bulk SMS file",
+ "parameters": [
+ {
+ "type": "file",
+ "description": "The Excel or CSV file containing the messages to be sent.",
+ "name": "document",
+ "in": "formData",
+ "required": true
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "Accepted",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/discord-integrations": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get the discord integrations of a user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "DiscordIntegration"
+ ],
+ "summary": "Get discord integrations of a user",
+ "parameters": [
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of discord integrations to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter discord integrations containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of discord integrations to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.DiscordsResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Store a discord integration for the authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "DiscordIntegration"
+ ],
+ "summary": "Store discord integration",
+ "parameters": [
+ {
+ "description": "Payload of the discord integration request",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.DiscordStore"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/responses.DiscordResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/discord-integrations/{discordID}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update a discord integration for the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "DiscordIntegration"
+ ],
+ "summary": "Update a discord integration",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the discord integration",
+ "name": "discordID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of discord integration to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.DiscordUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.DiscordResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a discord integration for a user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Delete discord integration",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the discord integration",
+ "name": "discordID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/discord/event": {
+ "post": {
+ "description": "Publish a discord event to the registered listeners",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Discord"
+ ],
+ "summary": "Consume a discord event",
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/heartbeats": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get the last time a phone number requested for outstanding messages. It will be sorted by timestamp in descending order.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Heartbeats"
+ ],
+ "summary": "Get heartbeats of an owner phone number",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "+18005550199",
+ "description": "the owner's phone number",
+ "name": "owner",
+ "in": "query",
+ "required": true
+ },
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of heartbeats to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of heartbeats to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.HeartbeatsResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Store the heartbeat to make notify that a phone number is still active",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Heartbeats"
+ ],
+ "summary": "Register heartbeat of an owner phone number",
+ "parameters": [
+ {
+ "description": "Payload of the heartbeat request",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.HeartbeatStore"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.HeartbeatResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/integration/3cx/messages": {
+ "post": {
+ "description": "Sends an SMS message from the 3CX platform",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "3CXIntegration"
+ ],
+ "summary": "Sends a 3CX SMS message",
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/message-threads": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get list of contacts which a phone number has communicated with (threads). It will be sorted by timestamp in descending order.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "MessageThreads"
+ ],
+ "summary": "Get message threads for a phone number",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "+18005550199",
+ "description": "owner phone number",
+ "name": "owner",
+ "in": "query",
+ "required": true
+ },
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of messages to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter message threads containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of messages to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageThreadsResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/message-threads/{messageThreadID}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Updates the details of a message thread",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "MessageThreads"
+ ],
+ "summary": "Update a message thread",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message thread",
+ "name": "messageThreadID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of message thread details to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageThreadUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a message thread from the database and also deletes all the messages in the thread.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "MessageThreads"
+ ],
+ "summary": "Delete a message thread from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message thread",
+ "name": "messageThreadID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get list of messages which are sent between 2 phone numbers. It will be sorted by timestamp in descending order.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Get messages which are sent between 2 phone numbers",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "+18005550199",
+ "description": "the owner's phone number",
+ "name": "owner",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "default": "+18005550100",
+ "description": "the contact's phone number",
+ "name": "contact",
+ "in": "query",
+ "required": true
+ },
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of messages to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter messages containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of messages to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessagesResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/bulk-send": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Add bulk SMS messages to be sent by the android phone",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Send bulk SMS messages",
+ "parameters": [
+ {
+ "description": "Bulk send message request payload",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageBulkSend"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/responses.MessagesResponse"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/calls/missed": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Register a missed call event on the mobile phone",
+ "parameters": [
+ {
+ "description": "Payload of the missed call event.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageCallMissed"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/outstanding": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get an outstanding message to be sent by an android phone",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Get an outstanding message",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703cb",
+ "description": "The ID of the message",
+ "name": "message_id",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/receive": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Add a new message received from a mobile phone",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Receive a new SMS message from a mobile phone",
+ "parameters": [
+ {
+ "description": "Received message request payload",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageReceive"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/search": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "This returns the list of all messages based on the filter criteria including missed calls",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Search all messages of a user",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/",
+ "name": "token",
+ "in": "header",
+ "required": true
+ },
+ {
+ "type": "string",
+ "default": "+18005550199,+18005550100",
+ "description": "the owner's phone numbers",
+ "name": "owners",
+ "in": "query",
+ "required": true
+ },
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of messages to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter messages containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 200,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of messages to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessagesResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/send": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Add a new SMS message to be sent by your Android phone",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Send an SMS message",
+ "parameters": [
+ {
+ "description": "Send message request payload",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageSend"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/{messageID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get a message from the database by the message ID.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Get a message from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a message from the database and removes the message content from the list of threads.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Delete a message from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/messages/{messageID}/events": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Use this endpoint to send events for a message when it is failed, sent or delivered by the mobile phone.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Messages"
+ ],
+ "summary": "Upsert an event for a message on the mobile phone",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the message",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of the event emitted.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageEvent"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phone-api-keys": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get list phone API keys which a user has registered on the httpSMS application",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "PhoneAPIKeys"
+ ],
+ "summary": "Get the phone API keys of a user",
+ "parameters": [
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of phone api keys to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter phone api keys with name containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of phone api keys to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneAPIKeysResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "PhoneAPIKeys"
+ ],
+ "summary": "Store phone API key",
+ "parameters": [
+ {
+ "description": "Payload of new phone API key.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.PhoneAPIKeyStoreRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneAPIKeyResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "402": {
+ "description": "Payment Required",
+ "schema": {
+ "$ref": "#/definitions/responses.PaymentRequired"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phone-api-keys/{phoneAPIKeyID}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a phone API Key from the database and cannot be used for authentication anymore.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "PhoneAPIKeys"
+ ],
+ "summary": "Delete a phone API key from the database.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the phone API key",
+ "name": "phoneAPIKeyID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "You will need to login again to the httpSMS app on your Android phone with a new phone API key.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "PhoneAPIKeys"
+ ],
+ "summary": "Remove the association of a phone from the phone API key.",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the phone API key",
+ "name": "phoneAPIKeyID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the phone",
+ "name": "phoneID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phones": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get list of phones which a user has registered on the http sms application",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Phones"
+ ],
+ "summary": "Get phones of a user",
+ "parameters": [
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of heartbeats to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter phones containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of phones to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhonesResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Phones"
+ ],
+ "summary": "Upsert Phone",
+ "parameters": [
+ {
+ "description": "Payload of new phone number.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.PhoneUpsert"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phones/fcm-token": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Phones"
+ ],
+ "summary": "Upserts the FCM token of a phone",
+ "parameters": [
+ {
+ "description": "Payload of new FCM token.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.PhoneFCMToken"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/phones/{phoneID}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a phone that has been sored in the database",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Phones"
+ ],
+ "summary": "Delete Phone",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the phone",
+ "name": "phoneID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/send-schedules": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "List all send schedules owned by the authenticated user.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "SendSchedules"
+ ],
+ "summary": "List send schedules",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageSendSchedulesResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Create a new send schedule for the authenticated user.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "SendSchedules"
+ ],
+ "summary": "Create send schedule",
+ "parameters": [
+ {
+ "description": "Payload of new send schedule.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageSendScheduleStore"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageSendScheduleResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "402": {
+ "description": "Payment Required",
+ "schema": {
+ "$ref": "#/definitions/responses.PaymentRequired"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/send-schedules/{scheduleID}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update a send schedule owned by the authenticated user.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "SendSchedules"
+ ],
+ "summary": "Update send schedule",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Schedule ID",
+ "name": "scheduleID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of updated send schedule.",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.MessageSendScheduleStore"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.MessageSendScheduleResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a send schedule owned by the authenticated user.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "SendSchedules"
+ ],
+ "summary": "Delete send schedule",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Schedule ID",
+ "name": "scheduleID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/me": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get details of the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get current user",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Updates the details of the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Update a user",
+ "parameters": [
+ {
+ "description": "Payload of user details to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.UserUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.PhoneResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Deletes the currently authenticated user together with all their data.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Delete a user",
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Cancel the subscription of the authenticated user.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Cancel the user's subscription",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription-update-url": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Fetches the subscription URL of the authenticated user.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Currently authenticated user subscription update URL",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.OkString"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription/invoices/{subscriptionInvoiceID}": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/pdf"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Generate a subscription payment invoice",
+ "parameters": [
+ {
+ "description": "Generate subscription payment invoice parameters",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.UserPaymentInvoice"
+ }
+ },
+ {
+ "type": "string",
+ "description": "ID of the subscription invoice to generate the PDF for",
+ "name": "subscriptionInvoiceID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/subscription/payments": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get the last 10 subscription payments.",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/{userID}/api-keys": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Rotate the user's API key in case the current API Key is compromised",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Rotate the user's API Key",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the user to update",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/users/{userID}/notifications": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update the email notification settings for a user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Update notification settings",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the user to update",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "User notification details to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.UserNotificationUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.UserResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}": {
+ "get": {
+ "description": "Download an MMS attachment by its path components",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "Attachments"
+ ],
+ "summary": "Download a message attachment",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "User ID",
+ "name": "userID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Message ID",
+ "name": "messageID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Attachment index",
+ "name": "attachmentIndex",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Filename with extension",
+ "name": "filename",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/responses.NotFound"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/webhooks": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Get the webhooks of a user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Get webhooks of a user",
+ "parameters": [
+ {
+ "minimum": 0,
+ "type": "integer",
+ "description": "number of webhooks to skip",
+ "name": "skip",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "filter webhooks containing query",
+ "name": "query",
+ "in": "query"
+ },
+ {
+ "maximum": 20,
+ "minimum": 1,
+ "type": "integer",
+ "description": "number of webhooks to return",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.WebhooksResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Store a webhook for the authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Store a webhook",
+ "parameters": [
+ {
+ "description": "Payload of the webhook request",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.WebhookStore"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.WebhookResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "/webhooks/{webhookID}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update a webhook for the currently authenticated user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Update a webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the webhook",
+ "name": "webhookID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Payload of webhook details to update",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/requests.WebhookUpdate"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/responses.WebhookResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a webhook for a user",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Webhooks"
+ ],
+ "summary": "Delete webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "default": "32343a19-da5e-4b1b-a767-3298a73703ca",
+ "description": "ID of the webhook",
+ "name": "webhookID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content",
+ "schema": {
+ "$ref": "#/definitions/responses.NoContent"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/responses.BadRequest"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/responses.Unauthorized"
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "schema": {
+ "$ref": "#/definitions/responses.UnprocessableEntity"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/responses.InternalServerError"
+ }
+ }
+ }
+ }
}
- }
},
- "responses.WebhookResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "$ref": "#/definitions/entities.Webhook"
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
+ "definitions": {
+ "entities.BillingUsage": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "end_timestamp",
+ "id",
+ "received_messages",
+ "sent_messages",
+ "start_timestamp",
+ "total_cost",
+ "updated_at",
+ "user_id"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "end_timestamp": {
+ "type": "string",
+ "example": "2022-01-31T23:59:59+00:00"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "received_messages": {
+ "type": "integer",
+ "example": 465
+ },
+ "sent_messages": {
+ "type": "integer",
+ "example": 321
+ },
+ "start_timestamp": {
+ "type": "string",
+ "example": "2022-01-01T00:00:00+00:00"
+ },
+ "total_cost": {
+ "type": "integer",
+ "example": 0
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.BulkMessage": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "delivered_count",
+ "failed_count",
+ "pending_count",
+ "request_id",
+ "scheduled_count",
+ "sent_count",
+ "total"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "delivered_count": {
+ "type": "integer",
+ "example": 25
+ },
+ "failed_count": {
+ "type": "integer",
+ "example": 5
+ },
+ "pending_count": {
+ "type": "integer",
+ "example": 30
+ },
+ "request_id": {
+ "type": "string",
+ "example": "bulk-32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "scheduled_count": {
+ "type": "integer",
+ "example": 50
+ },
+ "sent_count": {
+ "type": "integer",
+ "example": 40
+ },
+ "total": {
+ "type": "integer",
+ "example": 150
+ }
+ }
+ },
+ "entities.Discord": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "id",
+ "incoming_channel_id",
+ "name",
+ "server_id",
+ "updated_at",
+ "user_id"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "incoming_channel_id": {
+ "type": "string",
+ "example": "1095780203256627291"
+ },
+ "name": {
+ "type": "string",
+ "example": "Game Server"
+ },
+ "server_id": {
+ "type": "string",
+ "example": "1095778291488653372"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.Heartbeat": {
+ "type": "object",
+ "required": [
+ "charging",
+ "id",
+ "owner",
+ "timestamp",
+ "user_id",
+ "version"
+ ],
+ "properties": {
+ "charging": {
+ "type": "boolean",
+ "example": true
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "owner": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "timestamp": {
+ "type": "string",
+ "example": "2022-06-05T14:26:01.520828+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ },
+ "version": {
+ "type": "string",
+ "example": "344c10f"
+ }
+ }
+ },
+ "entities.Message": {
+ "type": "object",
+ "required": [
+ "attachments",
+ "contact",
+ "content",
+ "created_at",
+ "encrypted",
+ "id",
+ "max_send_attempts",
+ "order_timestamp",
+ "owner",
+ "request_received_at",
+ "send_attempt_count",
+ "sim",
+ "status",
+ "type",
+ "updated_at",
+ "user_id"
+ ],
+ "properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
+ "contact": {
+ "type": "string",
+ "example": "+18005550100"
+ },
+ "content": {
+ "type": "string",
+ "example": "This is a sample text message"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "delivered_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "encrypted": {
+ "type": "boolean",
+ "example": false
+ },
+ "expired_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "failed_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "failure_reason": {
+ "type": "string",
+ "example": "UNKNOWN"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "last_attempted_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "max_send_attempts": {
+ "type": "integer",
+ "example": 1
+ },
+ "order_timestamp": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "owner": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "received_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "request_id": {
+ "type": "string",
+ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
+ },
+ "request_received_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:01.520828+03:00"
+ },
+ "scheduled_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "scheduled_send_time": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "send_attempt_count": {
+ "type": "integer",
+ "example": 0
+ },
+ "send_time": {
+ "description": "SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message",
+ "type": "integer",
+ "example": 133414
+ },
+ "sent_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "sim": {
+ "description": "SIM is the SIM card to use to send the message\n* SMS1: use the SIM card in slot 1\n* SMS2: use the SIM card in slot 2\n* DEFAULT: used the default communication SIM card",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
+ "example": "DEFAULT"
+ },
+ "status": {
+ "type": "string",
+ "example": "pending"
+ },
+ "type": {
+ "type": "string",
+ "example": "mobile-terminated"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.MessageSendSchedule": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "id",
+ "name",
+ "timezone",
+ "updated_at",
+ "user_id",
+ "windows"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "name": {
+ "type": "string",
+ "example": "Business Hours"
+ },
+ "timezone": {
+ "type": "string",
+ "example": "Europe/Tallinn"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ },
+ "windows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.MessageSendScheduleWindow"
+ }
+ }
+ }
+ },
+ "entities.MessageSendScheduleWindow": {
+ "type": "object",
+ "required": [
+ "day_of_week",
+ "end_minute",
+ "start_minute"
+ ],
+ "properties": {
+ "day_of_week": {
+ "type": "integer",
+ "example": 1
+ },
+ "end_minute": {
+ "type": "integer",
+ "example": 1020
+ },
+ "start_minute": {
+ "type": "integer",
+ "example": 540
+ }
+ }
+ },
+ "entities.MessageThread": {
+ "type": "object",
+ "required": [
+ "color",
+ "contact",
+ "created_at",
+ "id",
+ "is_archived",
+ "last_message_content",
+ "last_message_id",
+ "order_timestamp",
+ "owner",
+ "status",
+ "updated_at",
+ "user_id"
+ ],
+ "properties": {
+ "color": {
+ "type": "string",
+ "example": "indigo"
+ },
+ "contact": {
+ "type": "string",
+ "example": "+18005550100"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703ca"
+ },
+ "is_archived": {
+ "type": "boolean",
+ "example": false
+ },
+ "last_message_content": {
+ "type": "string",
+ "example": "This is a sample message content"
+ },
+ "last_message_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703ca"
+ },
+ "order_timestamp": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "owner": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "status": {
+ "type": "string",
+ "example": "PENDING"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.Phone": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "id",
+ "max_send_attempts",
+ "message_expiration_seconds",
+ "messages_per_minute",
+ "phone_number",
+ "sim",
+ "updated_at",
+ "user_id"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "fcm_token": {
+ "type": "string",
+ "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "max_send_attempts": {
+ "description": "MaxSendAttempts determines how many times to retry sending an SMS message",
+ "type": "integer",
+ "example": 2
+ },
+ "message_expiration_seconds": {
+ "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.",
+ "type": "integer"
+ },
+ "message_send_schedule_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "messages_per_minute": {
+ "type": "integer",
+ "example": 1
+ },
+ "missed_call_auto_reply": {
+ "type": "string",
+ "example": "This phone cannot receive calls. Please send an SMS instead."
+ },
+ "phone_number": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "sim": {
+ "$ref": "#/definitions/entities.SIM"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.PhoneAPIKey": {
+ "type": "object",
+ "required": [
+ "api_key",
+ "created_at",
+ "id",
+ "name",
+ "phone_ids",
+ "phone_numbers",
+ "updated_at",
+ "user_email",
+ "user_id"
+ ],
+ "properties": {
+ "api_key": {
+ "type": "string",
+ "example": "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "name": {
+ "type": "string",
+ "example": "Business Phone Key"
+ },
+ "phone_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "32343a19-da5e-4b1b-a767-3298a73703cb",
+ "32343a19-da5e-4b1b-a767-3298a73703cc"
+ ]
+ },
+ "phone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "+18005550199",
+ "+18005550100"
+ ]
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "user_email": {
+ "type": "string",
+ "example": "user@gmail.com"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "entities.SIM": {
+ "type": "string",
+ "enum": [
+ "SIM1",
+ "SIM2"
+ ],
+ "x-enum-varnames": [
+ "SIM1",
+ "SIM2"
+ ]
+ },
+ "entities.SubscriptionName": {
+ "type": "string",
+ "enum": [
+ "free",
+ "pro-monthly",
+ "pro-yearly",
+ "ultra-monthly",
+ "ultra-yearly",
+ "pro-lifetime",
+ "20k-monthly",
+ "100k-monthly",
+ "50k-monthly",
+ "200k-monthly",
+ "20k-yearly"
+ ],
+ "x-enum-varnames": [
+ "SubscriptionNameFree",
+ "SubscriptionNameProMonthly",
+ "SubscriptionNameProYearly",
+ "SubscriptionNameUltraMonthly",
+ "SubscriptionNameUltraYearly",
+ "SubscriptionNameProLifetime",
+ "SubscriptionName20KMonthly",
+ "SubscriptionName100KMonthly",
+ "SubscriptionName50KMonthly",
+ "SubscriptionName200KMonthly",
+ "SubscriptionName20KYearly"
+ ]
+ },
+ "entities.User": {
+ "type": "object",
+ "required": [
+ "api_key",
+ "created_at",
+ "email",
+ "id",
+ "notification_heartbeat_enabled",
+ "notification_message_status_enabled",
+ "notification_newsletter_enabled",
+ "notification_webhook_enabled",
+ "subscription_id",
+ "subscription_name",
+ "timezone",
+ "updated_at"
+ ],
+ "properties": {
+ "active_phone_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "api_key": {
+ "type": "string",
+ "example": "x-api-key"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "email": {
+ "type": "string",
+ "example": "name@email.com"
+ },
+ "id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ },
+ "notification_heartbeat_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "notification_message_status_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "notification_newsletter_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "notification_webhook_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "subscription_ends_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "subscription_id": {
+ "type": "string",
+ "example": "8f9c71b8-b84e-4417-8408-a62274f65a08"
+ },
+ "subscription_name": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SubscriptionName"
+ }
+ ],
+ "example": "free"
+ },
+ "subscription_renews_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "subscription_status": {
+ "type": "string",
+ "example": "on_trial"
+ },
+ "timezone": {
+ "type": "string",
+ "example": "Europe/Helsinki"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ }
+ }
+ },
+ "entities.Webhook": {
+ "type": "object",
+ "required": [
+ "created_at",
+ "events",
+ "id",
+ "phone_numbers",
+ "signing_key",
+ "updated_at",
+ "url",
+ "user_id"
+ ],
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:02.302718+03:00"
+ },
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "message.phone.received"
+ ]
+ },
+ "id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "phone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "+18005550199",
+ "+18005550100"
+ ]
+ },
+ "signing_key": {
+ "type": "string",
+ "example": "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2022-06-05T14:26:10.303278+03:00"
+ },
+ "url": {
+ "type": "string",
+ "example": "https://example.com"
+ },
+ "user_id": {
+ "type": "string",
+ "example": "WB7DRDWrJZRGbYrv2CKGkqbzvqdC"
+ }
+ }
+ },
+ "requests.DiscordStore": {
+ "type": "object",
+ "required": [
+ "incoming_channel_id",
+ "name",
+ "server_id"
+ ],
+ "properties": {
+ "incoming_channel_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "server_id": {
+ "type": "string"
+ }
+ }
+ },
+ "requests.DiscordUpdate": {
+ "type": "object",
+ "required": [
+ "incoming_channel_id",
+ "name",
+ "server_id"
+ ],
+ "properties": {
+ "incoming_channel_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "server_id": {
+ "type": "string"
+ }
+ }
+ },
+ "requests.HeartbeatStore": {
+ "type": "object",
+ "required": [
+ "charging",
+ "phone_numbers"
+ ],
+ "properties": {
+ "charging": {
+ "type": "boolean"
+ },
+ "phone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "requests.MessageAttachment": {
+ "type": "object",
+ "required": [
+ "content",
+ "content_type",
+ "name"
+ ],
+ "properties": {
+ "content": {
+ "description": "Content is the base64-encoded attachment data",
+ "type": "string",
+ "example": "base64data..."
+ },
+ "content_type": {
+ "description": "ContentType is the MIME type of the attachment",
+ "type": "string",
+ "example": "image/jpeg"
+ },
+ "name": {
+ "description": "Name is the original filename of the attachment",
+ "type": "string",
+ "example": "photo.jpg"
+ }
+ }
+ },
+ "requests.MessageBulkSend": {
+ "type": "object",
+ "required": [
+ "content",
+ "from",
+ "to"
+ ],
+ "properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "content": {
+ "type": "string",
+ "example": "This is a sample text message"
+ },
+ "encrypted": {
+ "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
+ "type": "boolean",
+ "example": false
+ },
+ "from": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "request_id": {
+ "description": "RequestID is an optional parameter used to track a request from the client's perspective",
+ "type": "string",
+ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
+ },
+ "to": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "+18005550100",
+ "+18005550100"
+ ]
+ }
+ }
+ },
+ "requests.MessageCallMissed": {
+ "type": "object",
+ "required": [
+ "from",
+ "sim",
+ "timestamp",
+ "to"
+ ],
+ "properties": {
+ "from": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "sim": {
+ "type": "string",
+ "example": "SIM1"
+ },
+ "timestamp": {
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "to": {
+ "type": "string",
+ "example": "+18005550100"
+ }
+ }
+ },
+ "requests.MessageEvent": {
+ "type": "object",
+ "required": [
+ "event_name",
+ "reason",
+ "timestamp"
+ ],
+ "properties": {
+ "event_name": {
+ "description": "EventName is the type of event\n* SENT: is emitted when a message is sent by the mobile phone\n* FAILED: is event is emitted when the message could not be sent by the mobile phone\n* DELIVERED: is event is emitted when a delivery report has been received by the mobile phone",
+ "type": "string",
+ "example": "SENT"
+ },
+ "reason": {
+ "description": "Reason is the exact error message in case the event is an error",
+ "type": "string"
+ },
+ "timestamp": {
+ "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible",
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ }
+ }
+ },
+ "requests.MessageReceive": {
+ "type": "object",
+ "required": [
+ "content",
+ "encrypted",
+ "from",
+ "sim",
+ "timestamp",
+ "to"
+ ],
+ "properties": {
+ "attachments": {
+ "description": "Attachments is the list of MMS attachments received with the message",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageAttachment"
+ }
+ },
+ "content": {
+ "type": "string",
+ "example": "This is a sample text message received on a phone"
+ },
+ "encrypted": {
+ "description": "Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
+ "type": "boolean",
+ "example": false
+ },
+ "from": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "sim": {
+ "description": "SIM card that received the message",
+ "allOf": [
+ {
+ "$ref": "#/definitions/entities.SIM"
+ }
+ ],
+ "example": "SIM1"
+ },
+ "timestamp": {
+ "description": "Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible",
+ "type": "string",
+ "example": "2022-06-05T14:26:09.527976+03:00"
+ },
+ "to": {
+ "type": "string",
+ "example": "+18005550100"
+ }
+ }
+ },
+ "requests.MessageSend": {
+ "type": "object",
+ "required": [
+ "content",
+ "from",
+ "to"
+ ],
+ "properties": {
+ "attachments": {
+ "description": "Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "https://example.com/image.jpg",
+ "https://example.com/video.mp4"
+ ]
+ },
+ "content": {
+ "type": "string",
+ "example": "This is a sample text message"
+ },
+ "encrypted": {
+ "description": "Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app",
+ "type": "boolean",
+ "example": false
+ },
+ "from": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "request_id": {
+ "description": "RequestID is an optional parameter used to track a request from the client's perspective",
+ "type": "string",
+ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4"
+ },
+ "send_at": {
+ "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.",
+ "type": "string",
+ "example": "2025-12-19T16:39:57-08:00"
+ },
+ "to": {
+ "type": "string",
+ "example": "+18005550100"
+ }
+ }
+ },
+ "requests.MessageSendScheduleStore": {
+ "type": "object",
+ "required": [
+ "name",
+ "timezone",
+ "windows"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "windows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/requests.MessageSendScheduleWindow"
+ }
+ }
+ }
+ },
+ "requests.MessageSendScheduleWindow": {
+ "type": "object",
+ "required": [
+ "day_of_week",
+ "end_minute",
+ "start_minute"
+ ],
+ "properties": {
+ "day_of_week": {
+ "type": "integer"
+ },
+ "end_minute": {
+ "type": "integer"
+ },
+ "start_minute": {
+ "type": "integer"
+ }
+ }
+ },
+ "requests.MessageThreadUpdate": {
+ "type": "object",
+ "required": [
+ "is_archived"
+ ],
+ "properties": {
+ "is_archived": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "requests.PhoneAPIKeyStoreRequest": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "example": "My Phone API Key"
+ }
+ }
+ },
+ "requests.PhoneFCMToken": {
+ "type": "object",
+ "required": [
+ "fcm_token",
+ "phone_number",
+ "sim"
+ ],
+ "properties": {
+ "fcm_token": {
+ "type": "string",
+ "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
+ },
+ "phone_number": {
+ "type": "string",
+ "example": "[+18005550199]"
+ },
+ "sim": {
+ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
+ "type": "string",
+ "example": "SIM1"
+ }
+ }
+ },
+ "requests.PhoneUpsert": {
+ "type": "object",
+ "required": [
+ "fcm_token",
+ "max_send_attempts",
+ "message_expiration_seconds",
+ "messages_per_minute",
+ "missed_call_auto_reply",
+ "phone_number",
+ "sim"
+ ],
+ "properties": {
+ "fcm_token": {
+ "type": "string",
+ "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
+ },
+ "max_send_attempts": {
+ "description": "MaxSendAttempts is the number of attempts when sending an SMS message to handle the case where the phone is offline.",
+ "type": "integer",
+ "example": 2
+ },
+ "message_expiration_seconds": {
+ "description": "MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired.",
+ "type": "integer",
+ "example": 12345
+ },
+ "message_send_schedule_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "messages_per_minute": {
+ "type": "integer",
+ "example": 1
+ },
+ "missed_call_auto_reply": {
+ "type": "string",
+ "example": "e.g. This phone cannot receive calls. Please send an SMS instead."
+ },
+ "phone_number": {
+ "type": "string",
+ "example": "+18005550199"
+ },
+ "sim": {
+ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot",
+ "type": "string",
+ "example": "SIM1"
+ }
+ }
+ },
+ "requests.UserNotificationUpdate": {
+ "type": "object",
+ "required": [
+ "heartbeat_enabled",
+ "message_status_enabled",
+ "newsletter_enabled",
+ "webhook_enabled"
+ ],
+ "properties": {
+ "heartbeat_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "message_status_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "newsletter_enabled": {
+ "type": "boolean",
+ "example": true
+ },
+ "webhook_enabled": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "requests.UserPaymentInvoice": {
+ "type": "object",
+ "required": [
+ "address",
+ "city",
+ "country",
+ "name",
+ "notes",
+ "state",
+ "zip_code"
+ ],
+ "properties": {
+ "address": {
+ "type": "string",
+ "example": "221B Baker Street, London"
+ },
+ "city": {
+ "type": "string",
+ "example": "Los Angeles"
+ },
+ "country": {
+ "type": "string",
+ "example": "US"
+ },
+ "name": {
+ "type": "string",
+ "example": "Acme Corp"
+ },
+ "notes": {
+ "type": "string",
+ "example": "Thank you for your business!"
+ },
+ "state": {
+ "type": "string",
+ "example": "CA"
+ },
+ "zip_code": {
+ "type": "string",
+ "example": "9800"
+ }
+ }
+ },
+ "requests.UserUpdate": {
+ "type": "object",
+ "required": [
+ "active_phone_id",
+ "timezone"
+ ],
+ "properties": {
+ "active_phone_id": {
+ "type": "string",
+ "example": "32343a19-da5e-4b1b-a767-3298a73703cb"
+ },
+ "timezone": {
+ "type": "string",
+ "example": "Europe/Helsinki"
+ }
+ }
+ },
+ "requests.WebhookStore": {
+ "type": "object",
+ "required": [
+ "events",
+ "phone_numbers",
+ "signing_key",
+ "url"
+ ],
+ "properties": {
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "phone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "+18005550100",
+ "+18005550100"
+ ]
+ },
+ "signing_key": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
+ },
+ "requests.WebhookUpdate": {
+ "type": "object",
+ "required": [
+ "events",
+ "phone_numbers",
+ "signing_key",
+ "url"
+ ],
+ "properties": {
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "phone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "+18005550100",
+ "+18005550100"
+ ]
+ },
+ "signing_key": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
+ },
+ "responses.BadRequest": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "string",
+ "example": "The request body is not a valid JSON string"
+ },
+ "message": {
+ "type": "string",
+ "example": "The request isn't properly formed"
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.BillingUsageResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.BillingUsage"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.BillingUsagesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.BillingUsage"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.BulkMessagesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.BulkMessage"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.DiscordResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.Discord"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.DiscordsResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.Discord"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.HeartbeatResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.Heartbeat"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.HeartbeatsResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.Heartbeat"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.InternalServerError": {
+ "type": "object",
+ "required": [
+ "message",
+ "status"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "We ran into an internal error while handling the request."
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.MessageResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.Message"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.MessageSendScheduleResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.MessageSendSchedule"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.MessageSendSchedulesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.MessageSendSchedule"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.MessageThreadsResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.MessageThread"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.MessagesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.Message"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.NoContent": {
+ "type": "object",
+ "required": [
+ "message",
+ "status"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "action performed successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.NotFound": {
+ "type": "object",
+ "required": [
+ "message",
+ "status"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "cannot find message with ID [32343a19-da5e-4b1b-a767-3298a73703ca]"
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.OkString": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.PaymentRequired": {
+ "type": "object",
+ "required": [
+ "message",
+ "status"
+ ],
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You have reached the maximum number of allowed resources. Please upgrade your plan."
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.PhoneAPIKeyResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.PhoneAPIKey"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.PhoneAPIKeysResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.PhoneAPIKey"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.PhoneResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.Phone"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.PhonesResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.Phone"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.Unauthorized": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "string",
+ "example": "Make sure your API key is set in the [X-API-Key] header in the request"
+ },
+ "message": {
+ "type": "string",
+ "example": "You are not authorized to carry out this request."
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.UnprocessableEntity": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "validation errors while handling request"
+ },
+ "status": {
+ "type": "string",
+ "example": "error"
+ }
+ }
+ },
+ "responses.UserResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.User"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.UserSubscriptionPaymentsResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "attributes",
+ "id",
+ "type"
+ ],
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "required": [
+ "billing_reason",
+ "card_brand",
+ "card_last_four",
+ "created_at",
+ "currency",
+ "currency_rate",
+ "discount_total",
+ "discount_total_formatted",
+ "discount_total_usd",
+ "refunded",
+ "refunded_amount",
+ "refunded_amount_formatted",
+ "refunded_amount_usd",
+ "refunded_at",
+ "status",
+ "status_formatted",
+ "subtotal",
+ "subtotal_formatted",
+ "subtotal_usd",
+ "tax",
+ "tax_formatted",
+ "tax_inclusive",
+ "tax_usd",
+ "total",
+ "total_formatted",
+ "total_usd",
+ "updated_at"
+ ],
+ "properties": {
+ "billing_reason": {
+ "type": "string"
+ },
+ "card_brand": {
+ "type": "string"
+ },
+ "card_last_four": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "currency": {
+ "type": "string"
+ },
+ "currency_rate": {
+ "type": "string"
+ },
+ "discount_total": {
+ "type": "integer"
+ },
+ "discount_total_formatted": {
+ "type": "string"
+ },
+ "discount_total_usd": {
+ "type": "integer"
+ },
+ "refunded": {
+ "type": "boolean"
+ },
+ "refunded_amount": {
+ "type": "integer"
+ },
+ "refunded_amount_formatted": {
+ "type": "string"
+ },
+ "refunded_amount_usd": {
+ "type": "integer"
+ },
+ "refunded_at": {},
+ "status": {
+ "type": "string"
+ },
+ "status_formatted": {
+ "type": "string"
+ },
+ "subtotal": {
+ "type": "integer"
+ },
+ "subtotal_formatted": {
+ "type": "string"
+ },
+ "subtotal_usd": {
+ "type": "integer"
+ },
+ "tax": {
+ "type": "integer"
+ },
+ "tax_formatted": {
+ "type": "string"
+ },
+ "tax_inclusive": {
+ "type": "boolean"
+ },
+ "tax_usd": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "total_formatted": {
+ "type": "string"
+ },
+ "total_usd": {
+ "type": "integer"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
+ "id": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.WebhookResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "$ref": "#/definitions/entities.Webhook"
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
+ },
+ "responses.WebhooksResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "message",
+ "status"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/entities.Webhook"
+ }
+ },
+ "message": {
+ "type": "string",
+ "example": "Request handled successfully"
+ },
+ "status": {
+ "type": "string",
+ "example": "success"
+ }
+ }
}
- }
},
- "responses.WebhooksResponse": {
- "type": "object",
- "required": ["data", "message", "status"],
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/entities.Webhook"
- }
- },
- "message": {
- "type": "string",
- "example": "Request handled successfully"
- },
- "status": {
- "type": "string",
- "example": "success"
+ "securityDefinitions": {
+ "ApiKeyAuth": {
+ "type": "apiKey",
+ "name": "x-api-Key",
+ "in": "header"
}
- }
- }
- },
- "securityDefinitions": {
- "ApiKeyAuth": {
- "type": "apiKey",
- "name": "x-api-Key",
- "in": "header"
}
- }
}
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index efc316c4..4c6d0938 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -30,15 +30,51 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - created_at
- - end_timestamp
- - id
- - received_messages
- - sent_messages
- - start_timestamp
- - total_cost
- - updated_at
- - user_id
+ - created_at
+ - end_timestamp
+ - id
+ - received_messages
+ - sent_messages
+ - start_timestamp
+ - total_cost
+ - updated_at
+ - user_id
+ type: object
+ entities.BulkMessage:
+ properties:
+ created_at:
+ example: "2022-06-05T14:26:02.302718+03:00"
+ type: string
+ delivered_count:
+ example: 25
+ type: integer
+ failed_count:
+ example: 5
+ type: integer
+ pending_count:
+ example: 30
+ type: integer
+ request_id:
+ example: bulk-32343a19-da5e-4b1b-a767-3298a73703cb
+ type: string
+ scheduled_count:
+ example: 50
+ type: integer
+ sent_count:
+ example: 40
+ type: integer
+ total:
+ example: 150
+ type: integer
+ required:
+ - created_at
+ - delivered_count
+ - failed_count
+ - pending_count
+ - request_id
+ - scheduled_count
+ - sent_count
+ - total
type: object
entities.Discord:
properties:
@@ -64,13 +100,13 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - created_at
- - id
- - incoming_channel_id
- - name
- - server_id
- - updated_at
- - user_id
+ - created_at
+ - id
+ - incoming_channel_id
+ - name
+ - server_id
+ - updated_at
+ - user_id
type: object
entities.Heartbeat:
properties:
@@ -93,18 +129,22 @@ definitions:
example: 344c10f
type: string
required:
- - charging
- - id
- - owner
- - timestamp
- - user_id
- - version
+ - charging
+ - id
+ - owner
+ - timestamp
+ - user_id
+ - version
type: object
entities.Message:
properties:
- can_be_polled:
- example: false
- type: boolean
+ attachments:
+ example:
+ - https://example.com/image.jpg
+ - https://example.com/video.mp4
+ items:
+ type: string
+ type: array
contact:
example: "+18005550100"
type: string
@@ -163,8 +203,7 @@ definitions:
example: 0
type: integer
send_time:
- description:
- SendDuration is the number of nanoseconds from when the request
+ description: SendDuration is the number of nanoseconds from when the request
was received until when the mobile phone send the message
example: 133414
type: integer
@@ -172,13 +211,14 @@ definitions:
example: "2022-06-05T14:26:09.527976+03:00"
type: string
sim:
+ allOf:
+ - $ref: '#/definitions/entities.SIM'
description: |-
SIM is the SIM card to use to send the message
* SMS1: use the SIM card in slot 1
* SMS2: use the SIM card in slot 2
* DEFAULT: used the default communication SIM card
example: DEFAULT
- type: string
status:
example: pending
type: string
@@ -192,33 +232,71 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - can_be_polled
- - contact
- - content
- - created_at
- - delivered_at
- - encrypted
- - expired_at
- - failed_at
- - failure_reason
- - id
- - last_attempted_at
- - max_send_attempts
- - order_timestamp
- - owner
- - received_at
- - request_id
- - request_received_at
- - scheduled_at
- - scheduled_send_time
- - send_attempt_count
- - send_time
- - sent_at
- - sim
- - status
- - type
- - updated_at
- - user_id
+ - attachments
+ - contact
+ - content
+ - created_at
+ - encrypted
+ - id
+ - max_send_attempts
+ - order_timestamp
+ - owner
+ - request_received_at
+ - send_attempt_count
+ - sim
+ - status
+ - type
+ - updated_at
+ - user_id
+ type: object
+ entities.MessageSendSchedule:
+ properties:
+ created_at:
+ example: "2022-06-05T14:26:02.302718+03:00"
+ type: string
+ id:
+ example: 32343a19-da5e-4b1b-a767-3298a73703cb
+ type: string
+ name:
+ example: Business Hours
+ type: string
+ timezone:
+ example: Europe/Tallinn
+ type: string
+ updated_at:
+ example: "2022-06-05T14:26:10.303278+03:00"
+ type: string
+ user_id:
+ example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
+ type: string
+ windows:
+ items:
+ $ref: '#/definitions/entities.MessageSendScheduleWindow'
+ type: array
+ required:
+ - created_at
+ - id
+ - name
+ - timezone
+ - updated_at
+ - user_id
+ - windows
+ type: object
+ entities.MessageSendScheduleWindow:
+ properties:
+ day_of_week:
+ example: 1
+ type: integer
+ end_minute:
+ example: 1020
+ type: integer
+ start_minute:
+ example: 540
+ type: integer
+ required:
+ - day_of_week
+ - end_minute
+ - start_minute
type: object
entities.MessageThread:
properties:
@@ -259,18 +337,18 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - color
- - contact
- - created_at
- - id
- - is_archived
- - last_message_content
- - last_message_id
- - order_timestamp
- - owner
- - status
- - updated_at
- - user_id
+ - color
+ - contact
+ - created_at
+ - id
+ - is_archived
+ - last_message_content
+ - last_message_id
+ - order_timestamp
+ - owner
+ - status
+ - updated_at
+ - user_id
type: object
entities.Phone:
properties:
@@ -284,16 +362,17 @@ definitions:
example: 32343a19-da5e-4b1b-a767-3298a73703cb
type: string
max_send_attempts:
- description:
- MaxSendAttempts determines how many times to retry sending an
+ description: MaxSendAttempts determines how many times to retry sending an
SMS message
example: 2
type: integer
message_expiration_seconds:
- description:
- MessageExpirationSeconds is the duration in seconds after sending
+ description: MessageExpirationSeconds is the duration in seconds after sending
a message when it is considered to be expired.
type: integer
+ message_send_schedule_id:
+ example: 32343a19-da5e-4b1b-a767-3298a73703cb
+ type: string
messages_per_minute:
example: 1
type: integer
@@ -304,8 +383,7 @@ definitions:
example: "+18005550199"
type: string
sim:
- description: SIM card that received the message
- type: string
+ $ref: '#/definitions/entities.SIM'
updated_at:
example: "2022-06-05T14:26:10.303278+03:00"
type: string
@@ -313,17 +391,15 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - created_at
- - fcm_token
- - id
- - max_send_attempts
- - message_expiration_seconds
- - messages_per_minute
- - missed_call_auto_reply
- - phone_number
- - sim
- - updated_at
- - user_id
+ - created_at
+ - id
+ - max_send_attempts
+ - message_expiration_seconds
+ - messages_per_minute
+ - phone_number
+ - sim
+ - updated_at
+ - user_id
type: object
entities.PhoneAPIKey:
properties:
@@ -341,15 +417,15 @@ definitions:
type: string
phone_ids:
example:
- - 32343a19-da5e-4b1b-a767-3298a73703cb
- - 32343a19-da5e-4b1b-a767-3298a73703cc
+ - 32343a19-da5e-4b1b-a767-3298a73703cb
+ - 32343a19-da5e-4b1b-a767-3298a73703cc
items:
type: string
type: array
phone_numbers:
example:
- - "+18005550199"
- - "+18005550100"
+ - "+18005550199"
+ - "+18005550100"
items:
type: string
type: array
@@ -363,16 +439,50 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - api_key
- - created_at
- - id
- - name
- - phone_ids
- - phone_numbers
- - updated_at
- - user_email
- - user_id
+ - api_key
+ - created_at
+ - id
+ - name
+ - phone_ids
+ - phone_numbers
+ - updated_at
+ - user_email
+ - user_id
type: object
+ entities.SIM:
+ enum:
+ - SIM1
+ - SIM2
+ type: string
+ x-enum-varnames:
+ - SIM1
+ - SIM2
+ entities.SubscriptionName:
+ enum:
+ - free
+ - pro-monthly
+ - pro-yearly
+ - ultra-monthly
+ - ultra-yearly
+ - pro-lifetime
+ - 20k-monthly
+ - 100k-monthly
+ - 50k-monthly
+ - 200k-monthly
+ - 20k-yearly
+ type: string
+ x-enum-varnames:
+ - SubscriptionNameFree
+ - SubscriptionNameProMonthly
+ - SubscriptionNameProYearly
+ - SubscriptionNameUltraMonthly
+ - SubscriptionNameUltraYearly
+ - SubscriptionNameProLifetime
+ - SubscriptionName20KMonthly
+ - SubscriptionName100KMonthly
+ - SubscriptionName50KMonthly
+ - SubscriptionName200KMonthly
+ - SubscriptionName20KYearly
entities.User:
properties:
active_phone_id:
@@ -409,8 +519,9 @@ definitions:
example: 8f9c71b8-b84e-4417-8408-a62274f65a08
type: string
subscription_name:
+ allOf:
+ - $ref: '#/definitions/entities.SubscriptionName'
example: free
- type: string
subscription_renews_at:
example: "2022-06-05T14:26:02.302718+03:00"
type: string
@@ -424,22 +535,18 @@ definitions:
example: "2022-06-05T14:26:10.303278+03:00"
type: string
required:
- - active_phone_id
- - api_key
- - created_at
- - email
- - id
- - notification_heartbeat_enabled
- - notification_message_status_enabled
- - notification_newsletter_enabled
- - notification_webhook_enabled
- - subscription_ends_at
- - subscription_id
- - subscription_name
- - subscription_renews_at
- - subscription_status
- - timezone
- - updated_at
+ - api_key
+ - created_at
+ - email
+ - id
+ - notification_heartbeat_enabled
+ - notification_message_status_enabled
+ - notification_newsletter_enabled
+ - notification_webhook_enabled
+ - subscription_id
+ - subscription_name
+ - timezone
+ - updated_at
type: object
entities.Webhook:
properties:
@@ -448,7 +555,7 @@ definitions:
type: string
events:
example:
- - message.phone.received
+ - message.phone.received
items:
type: string
type: array
@@ -457,8 +564,8 @@ definitions:
type: string
phone_numbers:
example:
- - "+18005550199"
- - "+18005550100"
+ - "+18005550199"
+ - "+18005550100"
items:
type: string
type: array
@@ -475,14 +582,14 @@ definitions:
example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC
type: string
required:
- - created_at
- - events
- - id
- - phone_numbers
- - signing_key
- - updated_at
- - url
- - user_id
+ - created_at
+ - events
+ - id
+ - phone_numbers
+ - signing_key
+ - updated_at
+ - url
+ - user_id
type: object
requests.DiscordStore:
properties:
@@ -493,9 +600,9 @@ definitions:
server_id:
type: string
required:
- - incoming_channel_id
- - name
- - server_id
+ - incoming_channel_id
+ - name
+ - server_id
type: object
requests.DiscordUpdate:
properties:
@@ -506,9 +613,9 @@ definitions:
server_id:
type: string
required:
- - incoming_channel_id
- - name
- - server_id
+ - incoming_channel_id
+ - name
+ - server_id
type: object
requests.HeartbeatStore:
properties:
@@ -519,17 +626,41 @@ definitions:
type: string
type: array
required:
- - charging
- - phone_numbers
+ - charging
+ - phone_numbers
+ type: object
+ requests.MessageAttachment:
+ properties:
+ content:
+ description: Content is the base64-encoded attachment data
+ example: base64data...
+ type: string
+ content_type:
+ description: ContentType is the MIME type of the attachment
+ example: image/jpeg
+ type: string
+ name:
+ description: Name is the original filename of the attachment
+ example: photo.jpg
+ type: string
+ required:
+ - content
+ - content_type
+ - name
type: object
requests.MessageBulkSend:
properties:
+ attachments:
+ description: Attachments are optional. When you provide a list of attachments,
+ the message will be sent out as an MMS
+ items:
+ type: string
+ type: array
content:
example: This is a sample text message
type: string
encrypted:
- description:
- Encrypted is used to determine if the content is end-to-end encrypted.
+ description: Encrypted is used to determine if the content is end-to-end encrypted.
Make sure to set the encryption key on the httpSMS mobile app
example: false
type: boolean
@@ -537,23 +668,21 @@ definitions:
example: "+18005550199"
type: string
request_id:
- description:
- RequestID is an optional parameter used to track a request from
+ description: RequestID is an optional parameter used to track a request from
the client's perspective
example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4
type: string
to:
example:
- - "+18005550100"
- - "+18005550100"
+ - "+18005550100"
+ - "+18005550100"
items:
type: string
type: array
required:
- - content
- - encrypted
- - from
- - to
+ - content
+ - from
+ - to
type: object
requests.MessageCallMissed:
properties:
@@ -570,10 +699,10 @@ definitions:
example: "+18005550100"
type: string
required:
- - from
- - sim
- - timestamp
- - to
+ - from
+ - sim
+ - timestamp
+ - to
type: object
requests.MessageEvent:
properties:
@@ -589,24 +718,28 @@ definitions:
description: Reason is the exact error message in case the event is an error
type: string
timestamp:
- description:
- Timestamp is the time when the event was emitted, Please send
+ description: Timestamp is the time when the event was emitted, Please send
the timestamp in UTC with as much precision as possible
example: "2022-06-05T14:26:09.527976+03:00"
type: string
required:
- - event_name
- - reason
- - timestamp
+ - event_name
+ - reason
+ - timestamp
type: object
requests.MessageReceive:
properties:
+ attachments:
+ description: Attachments is the list of MMS attachments received with the
+ message
+ items:
+ $ref: '#/definitions/requests.MessageAttachment'
+ type: array
content:
example: This is a sample text message received on a phone
type: string
encrypted:
- description:
- Encrypted is used to determine if the content is end-to-end encrypted.
+ description: Encrypted is used to determine if the content is end-to-end encrypted.
Make sure to set the encryption key on the httpSMS mobile app
example: false
type: boolean
@@ -614,12 +747,12 @@ definitions:
example: "+18005550199"
type: string
sim:
+ allOf:
+ - $ref: '#/definitions/entities.SIM'
description: SIM card that received the message
example: SIM1
- type: string
timestamp:
- description:
- Timestamp is the time when the event was emitted, Please send
+ description: Timestamp is the time when the event was emitted, Please send
the timestamp in UTC with as much precision as possible
example: "2022-06-05T14:26:09.527976+03:00"
type: string
@@ -627,21 +760,29 @@ definitions:
example: "+18005550100"
type: string
required:
- - content
- - encrypted
- - from
- - sim
- - timestamp
- - to
+ - content
+ - encrypted
+ - from
+ - sim
+ - timestamp
+ - to
type: object
requests.MessageSend:
properties:
+ attachments:
+ description: Attachments are optional. When you provide a list of attachments,
+ the message will be sent out as an MMS
+ example:
+ - https://example.com/image.jpg
+ - https://example.com/video.mp4
+ items:
+ type: string
+ type: array
content:
example: This is a sample text message
type: string
encrypted:
- description:
- Encrypted is an optional parameter used to determine if the content
+ description: Encrypted is an optional parameter used to determine if the content
is end-to-end encrypted. Make sure to set the encryption key on the httpSMS
mobile app
example: false
@@ -650,25 +791,52 @@ definitions:
example: "+18005550199"
type: string
request_id:
- description:
- RequestID is an optional parameter used to track a request from
+ description: RequestID is an optional parameter used to track a request from
the client's perspective
example: 153554b5-ae44-44a0-8f4f-7bbac5657ad4
type: string
send_at:
- description:
- SendAt is an optional parameter used to schedule a message to
+ description: SendAt is an optional parameter used to schedule a message to
be sent in the future. The time is considered to be in your profile's local
- timezone.
- example: "2022-06-05T14:26:09.527976+03:00"
+ timezone and you can queue messages for up to 20 days (480 hours) in the
+ future.
+ example: "2025-12-19T16:39:57-08:00"
type: string
to:
example: "+18005550100"
type: string
required:
- - content
- - from
- - to
+ - content
+ - from
+ - to
+ type: object
+ requests.MessageSendScheduleStore:
+ properties:
+ name:
+ type: string
+ timezone:
+ type: string
+ windows:
+ items:
+ $ref: '#/definitions/requests.MessageSendScheduleWindow'
+ type: array
+ required:
+ - name
+ - timezone
+ - windows
+ type: object
+ requests.MessageSendScheduleWindow:
+ properties:
+ day_of_week:
+ type: integer
+ end_minute:
+ type: integer
+ start_minute:
+ type: integer
+ required:
+ - day_of_week
+ - end_minute
+ - start_minute
type: object
requests.MessageThreadUpdate:
properties:
@@ -676,7 +844,7 @@ definitions:
example: true
type: boolean
required:
- - is_archived
+ - is_archived
type: object
requests.PhoneAPIKeyStoreRequest:
properties:
@@ -684,7 +852,7 @@ definitions:
example: My Phone API Key
type: string
required:
- - name
+ - name
type: object
requests.PhoneFCMToken:
properties:
@@ -692,18 +860,17 @@ definitions:
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
type: string
phone_number:
- example: "[+18005550199]"
+ example: '[+18005550199]'
type: string
sim:
- description:
- SIM is the SIM slot of the phone in case the phone has more than
+ description: SIM is the SIM slot of the phone in case the phone has more than
1 SIM slot
example: SIM1
type: string
required:
- - fcm_token
- - phone_number
- - sim
+ - fcm_token
+ - phone_number
+ - sim
type: object
requests.PhoneUpsert:
properties:
@@ -711,17 +878,18 @@ definitions:
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
type: string
max_send_attempts:
- description:
- MaxSendAttempts is the number of attempts when sending an SMS
+ description: MaxSendAttempts is the number of attempts when sending an SMS
message to handle the case where the phone is offline.
example: 2
type: integer
message_expiration_seconds:
- description:
- MessageExpirationSeconds is the duration in seconds after sending
+ description: MessageExpirationSeconds is the duration in seconds after sending
a message when it is considered to be expired.
example: 12345
type: integer
+ message_send_schedule_id:
+ example: 32343a19-da5e-4b1b-a767-3298a73703cb
+ type: string
messages_per_minute:
example: 1
type: integer
@@ -732,19 +900,18 @@ definitions:
example: "+18005550199"
type: string
sim:
- description:
- SIM is the SIM slot of the phone in case the phone has more than
+ description: SIM is the SIM slot of the phone in case the phone has more than
1 SIM slot
example: SIM1
type: string
required:
- - fcm_token
- - max_send_attempts
- - message_expiration_seconds
- - messages_per_minute
- - missed_call_auto_reply
- - phone_number
- - sim
+ - fcm_token
+ - max_send_attempts
+ - message_expiration_seconds
+ - messages_per_minute
+ - missed_call_auto_reply
+ - phone_number
+ - sim
type: object
requests.UserNotificationUpdate:
properties:
@@ -761,10 +928,42 @@ definitions:
example: true
type: boolean
required:
- - heartbeat_enabled
- - message_status_enabled
- - newsletter_enabled
- - webhook_enabled
+ - heartbeat_enabled
+ - message_status_enabled
+ - newsletter_enabled
+ - webhook_enabled
+ type: object
+ requests.UserPaymentInvoice:
+ properties:
+ address:
+ example: 221B Baker Street, London
+ type: string
+ city:
+ example: Los Angeles
+ type: string
+ country:
+ example: US
+ type: string
+ name:
+ example: Acme Corp
+ type: string
+ notes:
+ example: Thank you for your business!
+ type: string
+ state:
+ example: CA
+ type: string
+ zip_code:
+ example: "9800"
+ type: string
+ required:
+ - address
+ - city
+ - country
+ - name
+ - notes
+ - state
+ - zip_code
type: object
requests.UserUpdate:
properties:
@@ -775,8 +974,8 @@ definitions:
example: Europe/Helsinki
type: string
required:
- - active_phone_id
- - timezone
+ - active_phone_id
+ - timezone
type: object
requests.WebhookStore:
properties:
@@ -786,8 +985,8 @@ definitions:
type: array
phone_numbers:
example:
- - "+18005550100"
- - "+18005550100"
+ - "+18005550100"
+ - "+18005550100"
items:
type: string
type: array
@@ -796,10 +995,10 @@ definitions:
url:
type: string
required:
- - events
- - phone_numbers
- - signing_key
- - url
+ - events
+ - phone_numbers
+ - signing_key
+ - url
type: object
requests.WebhookUpdate:
properties:
@@ -809,8 +1008,8 @@ definitions:
type: array
phone_numbers:
example:
- - "+18005550100"
- - "+18005550100"
+ - "+18005550100"
+ - "+18005550100"
items:
type: string
type: array
@@ -819,10 +1018,10 @@ definitions:
url:
type: string
required:
- - events
- - phone_numbers
- - signing_key
- - url
+ - events
+ - phone_numbers
+ - signing_key
+ - url
type: object
responses.BadRequest:
properties:
@@ -836,14 +1035,14 @@ definitions:
example: error
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.BillingUsageResponse:
properties:
data:
- $ref: "#/definitions/entities.BillingUsage"
+ $ref: '#/definitions/entities.BillingUsage'
message:
example: Request handled successfully
type: string
@@ -851,15 +1050,32 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.BillingUsagesResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.BillingUsage"
+ $ref: '#/definitions/entities.BillingUsage'
+ type: array
+ message:
+ example: Request handled successfully
+ type: string
+ status:
+ example: success
+ type: string
+ required:
+ - data
+ - message
+ - status
+ type: object
+ responses.BulkMessagesResponse:
+ properties:
+ data:
+ items:
+ $ref: '#/definitions/entities.BulkMessage'
type: array
message:
example: Request handled successfully
@@ -868,14 +1084,14 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.DiscordResponse:
properties:
data:
- $ref: "#/definitions/entities.Discord"
+ $ref: '#/definitions/entities.Discord'
message:
example: Request handled successfully
type: string
@@ -883,15 +1099,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.DiscordsResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.Discord"
+ $ref: '#/definitions/entities.Discord'
type: array
message:
example: Request handled successfully
@@ -900,14 +1116,14 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.HeartbeatResponse:
properties:
data:
- $ref: "#/definitions/entities.Heartbeat"
+ $ref: '#/definitions/entities.Heartbeat'
message:
example: Request handled successfully
type: string
@@ -915,15 +1131,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.HeartbeatsResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.Heartbeat"
+ $ref: '#/definitions/entities.Heartbeat'
type: array
message:
example: Request handled successfully
@@ -932,9 +1148,9 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.InternalServerError:
properties:
@@ -945,13 +1161,45 @@ definitions:
example: error
type: string
required:
- - message
- - status
+ - message
+ - status
type: object
responses.MessageResponse:
properties:
data:
- $ref: "#/definitions/entities.Message"
+ $ref: '#/definitions/entities.Message'
+ message:
+ example: Request handled successfully
+ type: string
+ status:
+ example: success
+ type: string
+ required:
+ - data
+ - message
+ - status
+ type: object
+ responses.MessageSendScheduleResponse:
+ properties:
+ data:
+ $ref: '#/definitions/entities.MessageSendSchedule'
+ message:
+ example: Request handled successfully
+ type: string
+ status:
+ example: success
+ type: string
+ required:
+ - data
+ - message
+ - status
+ type: object
+ responses.MessageSendSchedulesResponse:
+ properties:
+ data:
+ items:
+ $ref: '#/definitions/entities.MessageSendSchedule'
+ type: array
message:
example: Request handled successfully
type: string
@@ -959,15 +1207,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.MessageThreadsResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.MessageThread"
+ $ref: '#/definitions/entities.MessageThread'
type: array
message:
example: Request handled successfully
@@ -976,15 +1224,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.MessagesResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.Message"
+ $ref: '#/definitions/entities.Message'
type: array
message:
example: Request handled successfully
@@ -993,9 +1241,9 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.NoContent:
properties:
@@ -1006,8 +1254,8 @@ definitions:
example: success
type: string
required:
- - message
- - status
+ - message
+ - status
type: object
responses.NotFound:
properties:
@@ -1018,8 +1266,8 @@ definitions:
example: error
type: string
required:
- - message
- - status
+ - message
+ - status
type: object
responses.OkString:
properties:
@@ -1032,14 +1280,27 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
+ type: object
+ responses.PaymentRequired:
+ properties:
+ message:
+ example: You have reached the maximum number of allowed resources. Please
+ upgrade your plan.
+ type: string
+ status:
+ example: error
+ type: string
+ required:
+ - message
+ - status
type: object
responses.PhoneAPIKeyResponse:
properties:
data:
- $ref: "#/definitions/entities.PhoneAPIKey"
+ $ref: '#/definitions/entities.PhoneAPIKey'
message:
example: Request handled successfully
type: string
@@ -1047,15 +1308,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.PhoneAPIKeysResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.PhoneAPIKey"
+ $ref: '#/definitions/entities.PhoneAPIKey'
type: array
message:
example: Request handled successfully
@@ -1064,14 +1325,14 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.PhoneResponse:
properties:
data:
- $ref: "#/definitions/entities.Phone"
+ $ref: '#/definitions/entities.Phone'
message:
example: Request handled successfully
type: string
@@ -1079,15 +1340,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.PhonesResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.Phone"
+ $ref: '#/definitions/entities.Phone'
type: array
message:
example: Request handled successfully
@@ -1096,9 +1357,9 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.Unauthorized:
properties:
@@ -1112,9 +1373,9 @@ definitions:
example: error
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.UnprocessableEntity:
properties:
@@ -1131,14 +1392,14 @@ definitions:
example: error
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.UserResponse:
properties:
data:
- $ref: "#/definitions/entities.User"
+ $ref: '#/definitions/entities.User'
message:
example: Request handled successfully
type: string
@@ -1146,14 +1407,124 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
+ type: object
+ responses.UserSubscriptionPaymentsResponse:
+ properties:
+ data:
+ items:
+ properties:
+ attributes:
+ properties:
+ billing_reason:
+ type: string
+ card_brand:
+ type: string
+ card_last_four:
+ type: string
+ created_at:
+ type: string
+ currency:
+ type: string
+ currency_rate:
+ type: string
+ discount_total:
+ type: integer
+ discount_total_formatted:
+ type: string
+ discount_total_usd:
+ type: integer
+ refunded:
+ type: boolean
+ refunded_amount:
+ type: integer
+ refunded_amount_formatted:
+ type: string
+ refunded_amount_usd:
+ type: integer
+ refunded_at: {}
+ status:
+ type: string
+ status_formatted:
+ type: string
+ subtotal:
+ type: integer
+ subtotal_formatted:
+ type: string
+ subtotal_usd:
+ type: integer
+ tax:
+ type: integer
+ tax_formatted:
+ type: string
+ tax_inclusive:
+ type: boolean
+ tax_usd:
+ type: integer
+ total:
+ type: integer
+ total_formatted:
+ type: string
+ total_usd:
+ type: integer
+ updated_at:
+ type: string
+ required:
+ - billing_reason
+ - card_brand
+ - card_last_four
+ - created_at
+ - currency
+ - currency_rate
+ - discount_total
+ - discount_total_formatted
+ - discount_total_usd
+ - refunded
+ - refunded_amount
+ - refunded_amount_formatted
+ - refunded_amount_usd
+ - refunded_at
+ - status
+ - status_formatted
+ - subtotal
+ - subtotal_formatted
+ - subtotal_usd
+ - tax
+ - tax_formatted
+ - tax_inclusive
+ - tax_usd
+ - total
+ - total_formatted
+ - total_usd
+ - updated_at
+ type: object
+ id:
+ type: string
+ type:
+ type: string
+ required:
+ - attributes
+ - id
+ - type
+ type: object
+ type: array
+ message:
+ example: Request handled successfully
+ type: string
+ status:
+ example: success
+ type: string
+ required:
+ - data
+ - message
+ - status
type: object
responses.WebhookResponse:
properties:
data:
- $ref: "#/definitions/entities.Webhook"
+ $ref: '#/definitions/entities.Webhook'
message:
example: Request handled successfully
type: string
@@ -1161,15 +1532,15 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
responses.WebhooksResponse:
properties:
data:
items:
- $ref: "#/definitions/entities.Webhook"
+ $ref: '#/definitions/entities.Webhook'
type: array
message:
example: Request handled successfully
@@ -1178,17 +1549,16 @@ definitions:
example: success
type: string
required:
- - data
- - message
- - status
+ - data
+ - message
+ - status
type: object
host: api.httpsms.com
info:
contact:
email: support@httpsms.com
name: support@httpsms.com
- description:
- Use your Android phone to send and receive SMS messages via a simple
+ description: Use your Android phone to send and receive SMS messages via a simple
programmable API with end-to-end encryption.
license:
name: AGPL-3.0
@@ -1199,1848 +1569,2143 @@ paths:
/billing/usage:
get:
consumes:
- - application/json
- description:
- Get the summary of sent and received messages for a user in the
+ - application/json
+ description: Get the summary of sent and received messages for a user in the
current month
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.BillingUsageResponse"
+ $ref: '#/definitions/responses.BillingUsageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get Billing Usage.
tags:
- - Billing
+ - Billing
/billing/usage-history:
get:
consumes:
- - application/json
- description:
- Get billing usage records of sent and received messages for a user
+ - application/json
+ description: Get billing usage records of sent and received messages for a user
in the past. It will be sorted by timestamp in descending order.
parameters:
- - description: number of heartbeats to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: number of heartbeats to return
- in: query
- maximum: 100
- minimum: 1
- name: limit
- type: integer
+ - description: number of heartbeats to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: number of heartbeats to return
+ in: query
+ maximum: 100
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.BillingUsagesResponse"
+ $ref: '#/definitions/responses.BillingUsagesResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get billing usage history.
tags:
- - Billing
+ - Billing
/bulk-messages:
+ get:
+ consumes:
+ - application/json
+ description: Fetches the last 10 bulk message order summaries for the authenticated
+ user showing counts per status.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/responses.BulkMessagesResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: List bulk message orders
+ tags:
+ - BulkSMS
post:
consumes:
- - multipart/form-data
- description: Sends bulk SMS messages to multiple users from a CSV or Excel file.
+ - multipart/form-data
+ description: Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv)
+ or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).
parameters:
- - description: The Excel or CSV file formatted according to the templates
- in: formData
- name: document
- required: true
- type: file
+ - description: The Excel or CSV file containing the messages to be sent.
+ in: formData
+ name: document
+ required: true
+ type: file
produces:
- - application/json
+ - application/json
responses:
"202":
description: Accepted
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Store bulk SMS file
tags:
- - BulkSMS
+ - BulkSMS
/discord-integrations:
get:
consumes:
- - application/json
+ - application/json
description: Get the discord integrations of a user
parameters:
- - description: number of discord integrations to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter discord integrations containing query
- in: query
- name: query
- type: string
- - description: number of discord integrations to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - description: number of discord integrations to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter discord integrations containing query
+ in: query
+ name: query
+ type: string
+ - description: number of discord integrations to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.DiscordsResponse"
+ $ref: '#/definitions/responses.DiscordsResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get discord integrations of a user
tags:
- - DiscordIntegration
+ - DiscordIntegration
post:
consumes:
- - application/json
+ - application/json
description: Store a discord integration for the authenticated user
parameters:
- - description: Payload of the discord integration request
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.DiscordStore"
+ - description: Payload of the discord integration request
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.DiscordStore'
produces:
- - application/json
+ - application/json
responses:
"201":
description: Created
schema:
- $ref: "#/definitions/responses.DiscordResponse"
+ $ref: '#/definitions/responses.DiscordResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Store discord integration
tags:
- - DiscordIntegration
+ - DiscordIntegration
/discord-integrations/{discordID}:
delete:
consumes:
- - application/json
+ - application/json
description: Delete a discord integration for a user
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the discord integration
- in: path
- name: discordID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the discord integration
+ in: path
+ name: discordID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete discord integration
tags:
- - Webhooks
+ - Webhooks
put:
consumes:
- - application/json
+ - application/json
description: Update a discord integration for the currently authenticated user
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the discord integration
- in: path
- name: discordID
- required: true
- type: string
- - description: Payload of discord integration to update
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.DiscordUpdate"
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the discord integration
+ in: path
+ name: discordID
+ required: true
+ type: string
+ - description: Payload of discord integration to update
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.DiscordUpdate'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.DiscordResponse"
+ $ref: '#/definitions/responses.DiscordResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Update a discord integration
tags:
- - DiscordIntegration
+ - DiscordIntegration
/discord/event:
post:
consumes:
- - application/json
+ - application/json
description: Publish a discord event to the registered listeners
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
summary: Consume a discord event
tags:
- - Discord
+ - Discord
/heartbeats:
get:
consumes:
- - application/json
- description:
- Get the last time a phone number requested for outstanding messages.
+ - application/json
+ description: Get the last time a phone number requested for outstanding messages.
It will be sorted by timestamp in descending order.
parameters:
- - default: "+18005550199"
- description: the owner's phone number
- in: query
- name: owner
- required: true
- type: string
- - description: number of heartbeats to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter containing query
- in: query
- name: query
- type: string
- - description: number of heartbeats to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - default: "+18005550199"
+ description: the owner's phone number
+ in: query
+ name: owner
+ required: true
+ type: string
+ - description: number of heartbeats to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter containing query
+ in: query
+ name: query
+ type: string
+ - description: number of heartbeats to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.HeartbeatsResponse"
+ $ref: '#/definitions/responses.HeartbeatsResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get heartbeats of an owner phone number
tags:
- - Heartbeats
+ - Heartbeats
post:
consumes:
- - application/json
- description:
- Store the heartbeat to make notify that a phone number is still
+ - application/json
+ description: Store the heartbeat to make notify that a phone number is still
active
parameters:
- - description: Payload of the heartbeat request
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.HeartbeatStore"
+ - description: Payload of the heartbeat request
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.HeartbeatStore'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.HeartbeatResponse"
+ $ref: '#/definitions/responses.HeartbeatResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Register heartbeat of an owner phone number
tags:
- - Heartbeats
+ - Heartbeats
/integration/3cx/messages:
post:
consumes:
- - application/json
+ - application/json
description: Sends an SMS message from the 3CX platform
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
summary: Sends a 3CX SMS message
tags:
- - 3CXIntegration
- /lemonsqueezy/event:
- post:
- consumes:
- - application/json
- description: Publish a lemonsqueezy event to the registered listeners
- produces:
- - application/json
- responses:
- "204":
- description: No Content
- schema:
- $ref: "#/definitions/responses.NoContent"
- "400":
- description: Bad Request
- schema:
- $ref: "#/definitions/responses.BadRequest"
- "401":
- description: Unauthorized
- schema:
- $ref: "#/definitions/responses.Unauthorized"
- "422":
- description: Unprocessable Entity
- schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
- "500":
- description: Internal Server Error
- schema:
- $ref: "#/definitions/responses.InternalServerError"
- summary: Consume a lemonsqueezy event
- tags:
- - Lemonsqueezy
+ - 3CXIntegration
/message-threads:
get:
consumes:
- - application/json
- description:
- Get list of contacts which a phone number has communicated with
+ - application/json
+ description: Get list of contacts which a phone number has communicated with
(threads). It will be sorted by timestamp in descending order.
parameters:
- - default: "+18005550199"
- description: owner phone number
- in: query
- name: owner
- required: true
- type: string
- - description: number of messages to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter message threads containing query
- in: query
- name: query
- type: string
- - description: number of messages to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - default: "+18005550199"
+ description: owner phone number
+ in: query
+ name: owner
+ required: true
+ type: string
+ - description: number of messages to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter message threads containing query
+ in: query
+ name: query
+ type: string
+ - description: number of messages to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageThreadsResponse"
+ $ref: '#/definitions/responses.MessageThreadsResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get message threads for a phone number
tags:
- - MessageThreads
+ - MessageThreads
/message-threads/{messageThreadID}:
delete:
consumes:
- - application/json
- description:
- Delete a message thread from the database and also deletes all
+ - application/json
+ description: Delete a message thread from the database and also deletes all
the messages in the thread.
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the message thread
- in: path
- name: messageThreadID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message thread
+ in: path
+ name: messageThreadID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete a message thread from the database.
tags:
- - MessageThreads
+ - MessageThreads
put:
consumes:
- - application/json
+ - application/json
description: Updates the details of a message thread
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the message thread
- in: path
- name: messageThreadID
- required: true
- type: string
- - description: Payload of message thread details to update
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageThreadUpdate"
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message thread
+ in: path
+ name: messageThreadID
+ required: true
+ type: string
+ - description: Payload of message thread details to update
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageThreadUpdate'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneResponse"
+ $ref: '#/definitions/responses.PhoneResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Update a message thread
tags:
- - MessageThreads
+ - MessageThreads
/messages:
get:
consumes:
- - application/json
- description:
- Get list of messages which are sent between 2 phone numbers. It
+ - application/json
+ description: Get list of messages which are sent between 2 phone numbers. It
will be sorted by timestamp in descending order.
parameters:
- - default: "+18005550199"
- description: the owner's phone number
- in: query
- name: owner
- required: true
- type: string
- - default: "+18005550100"
- description: the contact's phone number
- in: query
- name: contact
- required: true
- type: string
- - description: number of messages to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter messages containing query
- in: query
- name: query
- type: string
- - description: number of messages to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - default: "+18005550199"
+ description: the owner's phone number
+ in: query
+ name: owner
+ required: true
+ type: string
+ - default: "+18005550100"
+ description: the contact's phone number
+ in: query
+ name: contact
+ required: true
+ type: string
+ - description: number of messages to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter messages containing query
+ in: query
+ name: query
+ type: string
+ - description: number of messages to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessagesResponse"
+ $ref: '#/definitions/responses.MessagesResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get messages which are sent between 2 phone numbers
tags:
- - Messages
+ - Messages
/messages/{messageID}:
delete:
consumes:
- - application/json
- description:
- Delete a message from the database and removes the message content
+ - application/json
+ description: Delete a message from the database and removes the message content
from the list of threads.
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the message
- in: path
- name: messageID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message
+ in: path
+ name: messageID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete a message from the database.
tags:
- - Messages
+ - Messages
+ get:
+ consumes:
+ - application/json
+ description: Get a message from the database by the message ID.
+ parameters:
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message
+ in: path
+ name: messageID
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No Content
+ schema:
+ $ref: '#/definitions/responses.MessageResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/responses.NotFound'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/responses.UnprocessableEntity'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Get a message from the database.
+ tags:
+ - Messages
/messages/{messageID}/events:
post:
consumes:
- - application/json
- description:
- Use this endpoint to send events for a message when it is failed,
+ - application/json
+ description: Use this endpoint to send events for a message when it is failed,
sent or delivered by the mobile phone.
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the message
- in: path
- name: messageID
- required: true
- type: string
- - description: Payload of the event emitted.
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageEvent"
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the message
+ in: path
+ name: messageID
+ required: true
+ type: string
+ - description: Payload of the event emitted.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageEvent'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageResponse"
+ $ref: '#/definitions/responses.MessageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Upsert an event for a message on the mobile phone
tags:
- - Messages
+ - Messages
/messages/bulk-send:
post:
consumes:
- - application/json
+ - application/json
description: Add bulk SMS messages to be sent by the android phone
parameters:
- - description: Bulk send message request payload
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageBulkSend"
+ - description: Bulk send message request payload
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageBulkSend'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
items:
- $ref: "#/definitions/responses.MessagesResponse"
+ $ref: '#/definitions/responses.MessagesResponse'
type: array
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Send bulk SMS messages
tags:
- - Messages
+ - Messages
/messages/calls/missed:
post:
consumes:
- - application/json
- description:
- This endpoint is called by the httpSMS android app to register
+ - application/json
+ description: This endpoint is called by the httpSMS android app to register
a missed call event on the mobile phone.
parameters:
- - description: Payload of the missed call event.
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageCallMissed"
+ - description: Payload of the missed call event.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageCallMissed'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageResponse"
+ $ref: '#/definitions/responses.MessageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Register a missed call event on the mobile phone
tags:
- - Messages
+ - Messages
/messages/outstanding:
get:
consumes:
- - application/json
+ - application/json
description: Get an outstanding message to be sent by an android phone
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703cb
- description: The ID of the message
- in: query
- name: message_id
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703cb
+ description: The ID of the message
+ in: query
+ name: message_id
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageResponse"
+ $ref: '#/definitions/responses.MessageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get an outstanding message
tags:
- - Messages
+ - Messages
/messages/receive:
post:
consumes:
- - application/json
+ - application/json
description: Add a new message received from a mobile phone
parameters:
- - description: Received message request payload
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageReceive"
+ - description: Received message request payload
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageReceive'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageResponse"
+ $ref: '#/definitions/responses.MessageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Receive a new SMS message from a mobile phone
tags:
- - Messages
+ - Messages
/messages/search:
get:
consumes:
- - application/json
- description:
- This returns the list of all messages based on the filter criteria
+ - application/json
+ description: This returns the list of all messages based on the filter criteria
including missed calls
parameters:
- - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/
- in: header
- name: token
- required: true
- type: string
- - default: +18005550199,+18005550100
- description: the owner's phone numbers
- in: query
- name: owners
- required: true
- type: string
- - description: number of messages to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter messages containing query
- in: query
- name: query
- type: string
- - description: number of messages to return
- in: query
- maximum: 200
- minimum: 1
- name: limit
- type: integer
+ - description: Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/
+ in: header
+ name: token
+ required: true
+ type: string
+ - default: +18005550199,+18005550100
+ description: the owner's phone numbers
+ in: query
+ name: owners
+ required: true
+ type: string
+ - description: number of messages to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter messages containing query
+ in: query
+ name: query
+ type: string
+ - description: number of messages to return
+ in: query
+ maximum: 200
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessagesResponse"
+ $ref: '#/definitions/responses.MessagesResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Search all messages of a user
tags:
- - Messages
+ - Messages
/messages/send:
post:
consumes:
- - application/json
- description: Add a new SMS message to be sent by the android phone
+ - application/json
+ description: Add a new SMS message to be sent by your Android phone
parameters:
- - description: PostSend message request payload
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.MessageSend"
+ - description: Send message request payload
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageSend'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.MessageResponse"
+ $ref: '#/definitions/responses.MessageResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
- summary: Send a new SMS message
+ - ApiKeyAuth: []
+ summary: Send an SMS message
tags:
- - Messages
+ - Messages
/phone-api-keys:
get:
consumes:
- - application/json
- description:
- Get list phone API keys which a user has registered on the httpSMS
+ - application/json
+ description: Get list phone API keys which a user has registered on the httpSMS
application
parameters:
- - description: number of phone api keys to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter phone api keys with name containing query
- in: query
- name: query
- type: string
- - description: number of phone api keys to return
- in: query
- maximum: 100
- minimum: 1
- name: limit
- type: integer
+ - description: number of phone api keys to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter phone api keys with name containing query
+ in: query
+ name: query
+ type: string
+ - description: number of phone api keys to return
+ in: query
+ maximum: 100
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneAPIKeysResponse"
+ $ref: '#/definitions/responses.PhoneAPIKeysResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get the phone API keys of a user
tags:
- - PhoneAPIKeys
+ - PhoneAPIKeys
post:
consumes:
- - application/json
- description:
- Creates a new phone API key which can be used to log in to the
+ - application/json
+ description: Creates a new phone API key which can be used to log in to the
httpSMS app on your Android phone
parameters:
- - description: Payload of new phone API key.
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.PhoneAPIKeyStoreRequest"
+ - description: Payload of new phone API key.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.PhoneAPIKeyStoreRequest'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneAPIKeyResponse"
+ $ref: '#/definitions/responses.PhoneAPIKeyResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
+ "402":
+ description: Payment Required
+ schema:
+ $ref: '#/definitions/responses.PaymentRequired'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Store phone API key
tags:
- - PhoneAPIKeys
+ - PhoneAPIKeys
/phone-api-keys/{phoneAPIKeyID}:
delete:
consumes:
- - application/json
- description:
- Delete a phone API Key from the database and cannot be used for
+ - application/json
+ description: Delete a phone API Key from the database and cannot be used for
authentication anymore.
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the phone API key
- in: path
- name: phoneAPIKeyID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the phone API key
+ in: path
+ name: phoneAPIKeyID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete a phone API key from the database.
tags:
- - PhoneAPIKeys
+ - PhoneAPIKeys
/phone-api-keys/{phoneAPIKeyID}/phones/{phoneID}:
delete:
consumes:
- - application/json
- description:
- You will need to login again to the httpSMS app on your Android
+ - application/json
+ description: You will need to login again to the httpSMS app on your Android
phone with a new phone API key.
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the phone API key
- in: path
- name: phoneAPIKeyID
- required: true
- type: string
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the phone
- in: path
- name: phoneID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the phone API key
+ in: path
+ name: phoneAPIKeyID
+ required: true
+ type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the phone
+ in: path
+ name: phoneID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"404":
description: Not Found
schema:
- $ref: "#/definitions/responses.NotFound"
+ $ref: '#/definitions/responses.NotFound'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Remove the association of a phone from the phone API key.
tags:
- - PhoneAPIKeys
+ - PhoneAPIKeys
/phones:
get:
consumes:
- - application/json
- description:
- Get list of phones which a user has registered on the http sms
+ - application/json
+ description: Get list of phones which a user has registered on the http sms
application
parameters:
- - description: number of heartbeats to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter phones containing query
- in: query
- name: query
- type: string
- - description: number of phones to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - description: number of heartbeats to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter phones containing query
+ in: query
+ name: query
+ type: string
+ - description: number of phones to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhonesResponse"
+ $ref: '#/definitions/responses.PhonesResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get phones of a user
tags:
- - Phones
+ - Phones
put:
consumes:
- - application/json
- description:
- Updates properties of a user's phone. If the phone with this number
+ - application/json
+ description: Updates properties of a user's phone. If the phone with this number
does not exist, a new one will be created. Think of this method like an 'upsert'
parameters:
- - description: Payload of new phone number.
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.PhoneUpsert"
+ - description: Payload of new phone number.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.PhoneUpsert'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneResponse"
+ $ref: '#/definitions/responses.PhoneResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Upsert Phone
tags:
- - Phones
+ - Phones
/phones/{phoneID}:
delete:
consumes:
- - application/json
+ - application/json
description: Delete a phone that has been sored in the database
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the phone
- in: path
- name: phoneID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the phone
+ in: path
+ name: phoneID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete Phone
tags:
- - Phones
+ - Phones
/phones/fcm-token:
put:
consumes:
- - application/json
- description:
- Updates the FCM token of a phone. If the phone with this number
+ - application/json
+ description: Updates the FCM token of a phone. If the phone with this number
does not exist, a new one will be created. Think of this method like an 'upsert'
parameters:
- - description: Payload of new FCM token.
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.PhoneFCMToken"
+ - description: Payload of new FCM token.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.PhoneFCMToken'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneResponse"
+ $ref: '#/definitions/responses.PhoneResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Upserts the FCM token of a phone
tags:
- - Phones
+ - Phones
+ /send-schedules:
+ get:
+ description: List all send schedules owned by the authenticated user.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/responses.MessageSendSchedulesResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: List send schedules
+ tags:
+ - SendSchedules
+ post:
+ consumes:
+ - application/json
+ description: Create a new send schedule for the authenticated user.
+ parameters:
+ - description: Payload of new send schedule.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageSendScheduleStore'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/responses.MessageSendScheduleResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "402":
+ description: Payment Required
+ schema:
+ $ref: '#/definitions/responses.PaymentRequired'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/responses.UnprocessableEntity'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Create send schedule
+ tags:
+ - SendSchedules
+ /send-schedules/{scheduleID}:
+ delete:
+ description: Delete a send schedule owned by the authenticated user.
+ parameters:
+ - description: Schedule ID
+ in: path
+ name: scheduleID
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No Content
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/responses.NotFound'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Delete send schedule
+ tags:
+ - SendSchedules
+ put:
+ consumes:
+ - application/json
+ description: Update a send schedule owned by the authenticated user.
+ parameters:
+ - description: Schedule ID
+ in: path
+ name: scheduleID
+ required: true
+ type: string
+ - description: Payload of updated send schedule.
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.MessageSendScheduleStore'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/responses.MessageSendScheduleResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/responses.NotFound'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/responses.UnprocessableEntity'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Update send schedule
+ tags:
+ - SendSchedules
/users/{userID}/api-keys:
delete:
consumes:
- - application/json
+ - application/json
description: Rotate the user's API key in case the current API Key is compromised
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the user to update
- in: path
- name: userID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the user to update
+ in: path
+ name: userID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.UserResponse"
+ $ref: '#/definitions/responses.UserResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Rotate the user's API Key
tags:
- - Users
+ - Users
/users/{userID}/notifications:
put:
consumes:
- - application/json
+ - application/json
description: Update the email notification settings for a user
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the user to update
- in: path
- name: userID
- required: true
- type: string
- - description: User notification details to update
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.UserNotificationUpdate"
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the user to update
+ in: path
+ name: userID
+ required: true
+ type: string
+ - description: User notification details to update
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.UserNotificationUpdate'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.UserResponse"
+ $ref: '#/definitions/responses.UserResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Update notification settings
tags:
- - Users
+ - Users
/users/me:
delete:
consumes:
- - application/json
- description:
- Deletes the currently authenticated user together with all their
+ - application/json
+ description: Deletes the currently authenticated user together with all their
data.
produces:
- - application/json
+ - application/json
responses:
"201":
description: Created
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete a user
tags:
- - Users
+ - Users
get:
consumes:
- - application/json
+ - application/json
description: Get details of the currently authenticated user
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.UserResponse"
+ $ref: '#/definitions/responses.UserResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get current user
tags:
- - Users
+ - Users
put:
consumes:
- - application/json
+ - application/json
description: Updates the details of the currently authenticated user
parameters:
- - description: Payload of user details to update
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.UserUpdate"
+ - description: Payload of user details to update
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.UserUpdate'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.PhoneResponse"
+ $ref: '#/definitions/responses.PhoneResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Update a user
tags:
- - Users
+ - Users
/users/subscription:
delete:
description: Cancel the subscription of the authenticated user.
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Cancel the user's subscription
tags:
- - Users
+ - Users
/users/subscription-update-url:
get:
description: Fetches the subscription URL of the authenticated user.
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.OkString"
+ $ref: '#/definitions/responses.OkString'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Currently authenticated user subscription update URL
tags:
- - Users
+ - Users
+ /users/subscription/invoices/{subscriptionInvoiceID}:
+ post:
+ consumes:
+ - application/json
+ description: Generates a new invoice PDF file for the given subscription payment
+ with given parameters.
+ parameters:
+ - description: Generate subscription payment invoice parameters
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.UserPaymentInvoice'
+ - description: ID of the subscription invoice to generate the PDF for
+ in: path
+ name: subscriptionInvoiceID
+ required: true
+ type: string
+ produces:
+ - application/pdf
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: file
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/responses.UnprocessableEntity'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Generate a subscription payment invoice
+ tags:
+ - Users
+ /users/subscription/payments:
+ get:
+ consumes:
+ - application/json
+ description: Subscription payments are generated throughout the lifecycle of
+ a subscription, typically there is one at the time of purchase and then one
+ for each renewal.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/responses.UserSubscriptionPaymentsResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/responses.BadRequest'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/responses.Unauthorized'
+ "422":
+ description: Unprocessable Entity
+ schema:
+ $ref: '#/definitions/responses.UnprocessableEntity'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ security:
+ - ApiKeyAuth: []
+ summary: Get the last 10 subscription payments.
+ tags:
+ - Users
+ /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename}:
+ get:
+ description: Download an MMS attachment by its path components
+ parameters:
+ - description: User ID
+ in: path
+ name: userID
+ required: true
+ type: string
+ - description: Message ID
+ in: path
+ name: messageID
+ required: true
+ type: string
+ - description: Attachment index
+ in: path
+ name: attachmentIndex
+ required: true
+ type: string
+ - description: Filename with extension
+ in: path
+ name: filename
+ required: true
+ type: string
+ produces:
+ - application/octet-stream
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: file
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/responses.NotFound'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/responses.InternalServerError'
+ summary: Download a message attachment
+ tags:
+ - Attachments
/webhooks:
get:
consumes:
- - application/json
+ - application/json
description: Get the webhooks of a user
parameters:
- - description: number of webhooks to skip
- in: query
- minimum: 0
- name: skip
- type: integer
- - description: filter webhooks containing query
- in: query
- name: query
- type: string
- - description: number of webhooks to return
- in: query
- maximum: 20
- minimum: 1
- name: limit
- type: integer
+ - description: number of webhooks to skip
+ in: query
+ minimum: 0
+ name: skip
+ type: integer
+ - description: filter webhooks containing query
+ in: query
+ name: query
+ type: string
+ - description: number of webhooks to return
+ in: query
+ maximum: 20
+ minimum: 1
+ name: limit
+ type: integer
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.WebhooksResponse"
+ $ref: '#/definitions/responses.WebhooksResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Get webhooks of a user
tags:
- - Webhooks
+ - Webhooks
post:
consumes:
- - application/json
+ - application/json
description: Store a webhook for the authenticated user
parameters:
- - description: Payload of the webhook request
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.WebhookStore"
+ - description: Payload of the webhook request
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.WebhookStore'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.WebhookResponse"
+ $ref: '#/definitions/responses.WebhookResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Store a webhook
tags:
- - Webhooks
+ - Webhooks
/webhooks/{webhookID}:
delete:
consumes:
- - application/json
+ - application/json
description: Delete a webhook for a user
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the webhook
- in: path
- name: webhookID
- required: true
- type: string
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the webhook
+ in: path
+ name: webhookID
+ required: true
+ type: string
produces:
- - application/json
+ - application/json
responses:
"204":
description: No Content
schema:
- $ref: "#/definitions/responses.NoContent"
+ $ref: '#/definitions/responses.NoContent'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Delete webhook
tags:
- - Webhooks
+ - Webhooks
put:
consumes:
- - application/json
+ - application/json
description: Update a webhook for the currently authenticated user
parameters:
- - default: 32343a19-da5e-4b1b-a767-3298a73703ca
- description: ID of the webhook
- in: path
- name: webhookID
- required: true
- type: string
- - description: Payload of webhook details to update
- in: body
- name: payload
- required: true
- schema:
- $ref: "#/definitions/requests.WebhookUpdate"
+ - default: 32343a19-da5e-4b1b-a767-3298a73703ca
+ description: ID of the webhook
+ in: path
+ name: webhookID
+ required: true
+ type: string
+ - description: Payload of webhook details to update
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/requests.WebhookUpdate'
produces:
- - application/json
+ - application/json
responses:
"200":
description: OK
schema:
- $ref: "#/definitions/responses.WebhookResponse"
+ $ref: '#/definitions/responses.WebhookResponse'
"400":
description: Bad Request
schema:
- $ref: "#/definitions/responses.BadRequest"
+ $ref: '#/definitions/responses.BadRequest'
"401":
description: Unauthorized
schema:
- $ref: "#/definitions/responses.Unauthorized"
+ $ref: '#/definitions/responses.Unauthorized'
"422":
description: Unprocessable Entity
schema:
- $ref: "#/definitions/responses.UnprocessableEntity"
+ $ref: '#/definitions/responses.UnprocessableEntity'
"500":
description: Internal Server Error
schema:
- $ref: "#/definitions/responses.InternalServerError"
+ $ref: '#/definitions/responses.InternalServerError'
security:
- - ApiKeyAuth: []
+ - ApiKeyAuth: []
summary: Update a webhook
tags:
- - Webhooks
+ - Webhooks
schemes:
- - https
+- https
securityDefinitions:
ApiKeyAuth:
in: header
diff --git a/api/go.mod b/api/go.mod
index 91454eef..d8f600c7 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -1,191 +1,210 @@
module github.com/NdoleStudio/httpsms
-go 1.24.2
-
-toolchain go1.24.3
+go 1.25.8
require (
- cloud.google.com/go/cloudtasks v1.13.7
+ cloud.google.com/go/cloudtasks v1.18.0
+ cloud.google.com/go/storage v1.62.2
firebase.google.com/go v3.13.0+incompatible
- github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0
- github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0
- github.com/NdoleStudio/go-otelroundtripper v0.0.13
- github.com/NdoleStudio/lemonsqueezy-go v1.2.4
- github.com/NdoleStudio/plunk-go v0.0.1
- github.com/avast/retry-go v3.0.0+incompatible
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.0
+ github.com/NdoleStudio/go-otelroundtripper v0.0.15
+ github.com/NdoleStudio/lemonsqueezy-go v1.3.1
+ github.com/NdoleStudio/plunk-go v0.0.2
+ github.com/avast/retry-go/v5 v5.0.0
+ github.com/axiomhq/axiom-go v0.32.0
github.com/carlmjohnson/requests v0.25.1
github.com/cloudevents/sdk-go/v2 v2.16.2
- github.com/cockroachdb/cockroach-go/v2 v2.4.2
+ github.com/cockroachdb/cockroach-go/v2 v2.4.3
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
- github.com/dgraph-io/ristretto/v2 v2.3.0
+ github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/dustin/go-humanize v1.0.1
+ github.com/gertd/go-pluralize v0.2.1
+ github.com/go-hermes/hermes/v2 v2.6.2
github.com/gofiber/contrib/otelfiber v1.0.10
- github.com/gofiber/fiber/v2 v2.52.10
+ github.com/gofiber/fiber/v2 v2.52.13
github.com/gofiber/swagger v1.1.1
- github.com/golang-jwt/jwt/v5 v5.3.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/hirosassa/zerodriver v0.1.4
- github.com/jaswdr/faker/v2 v2.9.0
- github.com/jinzhu/now v1.1.5
+ github.com/jaswdr/faker/v2 v2.9.1
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/jszwec/csvutil v1.10.0
- github.com/lib/pq v1.10.9
- github.com/matcornic/hermes v1.3.0
- github.com/nyaruka/phonenumbers v1.6.7
+ github.com/lib/pq v1.12.3
+ github.com/nyaruka/phonenumbers v1.7.5
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/pusher/pusher-http-go/v5 v5.1.1
- github.com/redis/go-redis/extra/redisotel/v9 v9.17.2
- github.com/redis/go-redis/v9 v9.17.2
- github.com/rs/zerolog v1.34.0
+ github.com/redis/go-redis/extra/redisotel/v9 v9.20.0
+ github.com/redis/go-redis/v9 v9.20.0
+ github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6
github.com/thedevsaddam/govalidator v1.9.10
- github.com/uptrace/uptrace-go v1.38.0
- github.com/xuri/excelize/v2 v2.10.0
- go.opentelemetry.io/otel v1.38.0
- go.opentelemetry.io/otel/metric v1.38.0
- go.opentelemetry.io/otel/sdk v1.38.0
- go.opentelemetry.io/otel/sdk/metric v1.38.0
- go.opentelemetry.io/otel/trace v1.38.0
- google.golang.org/api v0.256.0
- google.golang.org/protobuf v1.36.10
+ github.com/uptrace/uptrace-go v1.43.0
+ github.com/xuri/excelize/v2 v2.10.1
+ go.mongodb.org/mongo-driver/v2 v2.6.0
+ go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e
+ go.opentelemetry.io/otel v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
+ go.opentelemetry.io/otel/metric v1.44.0
+ go.opentelemetry.io/otel/sdk v1.44.0
+ go.opentelemetry.io/otel/sdk/metric v1.44.0
+ go.opentelemetry.io/otel/trace v1.44.0
+ golang.org/x/sync v0.20.0
+ google.golang.org/api v0.282.0
+ google.golang.org/protobuf v1.36.11
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
)
require (
- cel.dev/expr v0.24.0 // indirect
+ github.com/Masterminds/semver/v3 v3.5.0 // indirect
+ github.com/Masterminds/sprig/v3 v3.3.0 // indirect
+ github.com/inbucket/html2text v1.0.0 // indirect
+ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
+ github.com/olekukonko/errors v1.3.0 // indirect
+ github.com/olekukonko/ll v0.1.8 // indirect
+ github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/yuin/goldmark v1.8.2 // indirect
+)
+
+require (
+ cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
- cloud.google.com/go/auth v0.17.0 // indirect
+ cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
- cloud.google.com/go/firestore v1.19.0 // indirect
- cloud.google.com/go/iam v1.5.3 // indirect
- cloud.google.com/go/longrunning v0.7.0 // indirect
- cloud.google.com/go/monitoring v1.24.3 // indirect
- cloud.google.com/go/storage v1.57.0 // indirect
- cloud.google.com/go/trace v1.11.7 // indirect
+ cloud.google.com/go/firestore v1.22.0 // indirect
+ cloud.google.com/go/iam v1.10.0 // indirect
+ cloud.google.com/go/longrunning v0.12.0 // indirect
+ cloud.google.com/go/monitoring v1.28.0 // indirect
+ cloud.google.com/go/trace v1.15.0 // indirect
dario.cat/mergo v1.0.2 // indirect
- filippo.io/edwards25519 v1.1.0 // indirect
- github.com/ClickHouse/ch-go v0.69.0 // indirect
- github.com/ClickHouse/clickhouse-go/v2 v2.40.3 // indirect
- github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
- github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/ClickHouse/ch-go v0.71.0 // indirect
+ github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/Masterminds/semver v1.5.0 // indirect
- github.com/Masterminds/sprig v2.22.0+incompatible // indirect
- github.com/PuerkitoBio/goquery v1.10.3 // indirect
- github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/PuerkitoBio/goquery v1.12.0 // indirect
+ github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/buger/jsonparser v1.1.2 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
- github.com/cncf/xds/go v0.0.0-20251014123835-2ee22ca58382 // indirect
- github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
- github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
- github.com/fatih/color v1.18.0 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+ github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+ github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
- github.com/go-jose/go-jose/v4 v4.1.3 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-openapi/jsonpointer v0.22.1 // indirect
- github.com/go-openapi/jsonreference v0.21.2 // indirect
- github.com/go-openapi/spec v0.22.0 // indirect
- github.com/go-openapi/swag/conv v0.25.1 // indirect
- github.com/go-openapi/swag/jsonname v0.25.1 // indirect
- github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
- github.com/go-openapi/swag/loading v0.25.1 // indirect
- github.com/go-openapi/swag/stringutils v0.25.1 // indirect
- github.com/go-openapi/swag/typeutils v0.25.1 // indirect
- github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
- github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/go-openapi/jsonpointer v0.23.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.5 // indirect
+ github.com/go-openapi/spec v0.22.4 // indirect
+ github.com/go-openapi/swag/conv v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonname v0.26.0 // indirect
+ github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
+ github.com/go-openapi/swag/loading v0.26.0 // indirect
+ github.com/go-openapi/swag/stringutils v0.26.0 // indirect
+ github.com/go-openapi/swag/typeutils v0.26.0 // indirect
+ github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
+ github.com/go-sql-driver/mysql v1.10.0 // indirect
+ github.com/goccy/go-json v0.10.6 // indirect
github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
- github.com/googleapis/gax-go/v2 v2.15.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
+ github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
- github.com/hashicorp/go-version v1.7.0 // indirect
+ github.com/hashicorp/go-version v1.9.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
- github.com/imdario/mergo v0.3.16 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.7.6 // indirect
+ github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
- github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
+ github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/olekukonko/tablewriter v0.0.5 // indirect
- github.com/paulmach/orb v0.12.0 // indirect
- github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/olekukonko/tablewriter v1.1.4 // indirect
+ github.com/paulmach/orb v0.13.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/redis/go-redis/extra/rediscmd/v9 v9.17.2 // indirect
- github.com/richardlehane/mscfb v1.0.4 // indirect
- github.com/richardlehane/msoleps v1.0.4 // indirect
- github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/redis/go-redis/extra/rediscmd/v9 v9.20.0 // indirect
+ github.com/richardlehane/mscfb v1.0.6 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
- github.com/tiendc/go-deepcopy v1.7.1 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.67.0 // indirect
+ github.com/valyala/fasthttp v1.71.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
- github.com/vanng822/go-premailer v1.25.0 // indirect
+ github.com/vanng822/go-premailer v1.33.0 // indirect
+ github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+ github.com/xdg-go/scram v1.2.0 // indirect
+ github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
+ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib v1.38.0 // indirect
- go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
- go.opentelemetry.io/otel/log v0.14.0 // indirect
- go.opentelemetry.io/otel/sdk/log v0.14.0 // indirect
- go.opentelemetry.io/proto/otlp v1.8.0 // indirect
+ go.opentelemetry.io/contrib v1.43.0 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 // indirect
+ go.opentelemetry.io/contrib/processors/minsev v0.16.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/log v0.19.0 // indirect
+ go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.45.0 // indirect
- golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect
- golang.org/x/mod v0.29.0 // indirect
- golang.org/x/net v0.47.0 // indirect
- golang.org/x/oauth2 v0.33.0 // indirect
- golang.org/x/sync v0.18.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.31.0 // indirect
- golang.org/x/time v0.14.0 // indirect
- golang.org/x/tools v0.38.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
- google.golang.org/genproto v0.0.0-20251014184007-4626949a642f // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
- google.golang.org/grpc v1.76.0 // indirect
+ google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/grpc v1.81.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
diff --git a/api/go.sum b/api/go.sum
index 86e6039d..33e3dfbb 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -1,174 +1,189 @@
bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI=
bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA=
-cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
-cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
-cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
-cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
-cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I=
-cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4=
+cloud.google.com/go/cloudtasks v1.18.0 h1:KzT7hfix/9/xAf20tNPIxwX59XGpRF0Lun2t8LHOj9E=
+cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
-cloud.google.com/go/firestore v1.19.0 h1:E3FiRsWfZKwZ6W+Lsp1YqTzZ9H6jP+QsKW40KR21C8I=
-cloud.google.com/go/firestore v1.19.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
-cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
-cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
-cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
-cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
-cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
-cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
-cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
-cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
-cloud.google.com/go/storage v1.57.0 h1:4g7NB7Ta7KetVbOMpCqy89C+Vg5VE8scqlSHUPm7Rds=
-cloud.google.com/go/storage v1.57.0/go.mod h1:329cwlpzALLgJuu8beyJ/uvQznDHpa2U5lGjWednkzg=
-cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
-cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
+cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
+cloud.google.com/go/iam v1.10.0 h1:cWWt8u8jXv3MzpvBmQgNClvvbVCRukruCJAnoK3fIJY=
+cloud.google.com/go/iam v1.10.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
+cloud.google.com/go/logging v1.17.0 h1:rUFekZYwHiKElXCyz3zYBGz4BOeIqzgCKxVLdgrZ5mY=
+cloud.google.com/go/logging v1.17.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
+cloud.google.com/go/longrunning v0.12.0 h1:wLv2hXvID9zHejLtcPo1B0JBjErnwZCYAPKSTa65xpY=
+cloud.google.com/go/longrunning v0.12.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
+cloud.google.com/go/monitoring v1.28.0 h1:jOe0Wkm+a56ptZnEeyHevXo7+KPWAPPP5wUTEJdP7GY=
+cloud.google.com/go/monitoring v1.28.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
+cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
+cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
+cloud.google.com/go/trace v1.15.0 h1:kAYkTwKyYHkGtAGFuu6qaUFRBkOVr+d1Yo44yZtGtgg=
+cloud.google.com/go/trace v1.15.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
-filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
-filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
-github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
-github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg=
-github.com/ClickHouse/clickhouse-go/v2 v2.40.3 h1:46jB4kKwVDUOnECpStKMVXxvR0Cg9zeV9vdbPjtn6po=
-github.com/ClickHouse/clickhouse-go/v2 v2.40.3/go.mod h1:qO0HwvjCnTB4BPL/k6EE3l4d9f/uF+aoimAhJX70eKA=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 h1:5eCqTd9rTwMlE62z0xFdzPJ+3pji75hJrwq1jrCjo5w=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0/go.mod h1:4BcvJy7WxY8X2eX49z2VO1ByhO+CcQK8lKPCH/QlZvo=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
+github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
+github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
+github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk=
+github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.0 h1:ftVmySBwuOJafpEJnnZvco+iV3p6Lokgu2sd89/qY7M=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.32.0/go.mod h1:nikqFGPI5OGwEsdxXzd3f58sB3tzkjqpqwYOV/S1rmo=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
-github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
-github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
-github.com/NdoleStudio/go-otelroundtripper v0.0.13 h1:fDgdxcNJov4LTrMhXqJnF/E3jO4HJVczj90wkxh5PSc=
-github.com/NdoleStudio/go-otelroundtripper v0.0.13/go.mod h1:UIUQ22ErFoBUyLuPDrVNRRKmBHBTfzQO9GF1ztqDvqo=
-github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA=
-github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw=
-github.com/NdoleStudio/plunk-go v0.0.1 h1:nWPr5pcwFDvhYGZS5n3a3cKGkQvg5re9DSAiFMZCFvs=
-github.com/NdoleStudio/plunk-go v0.0.1/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8=
-github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
-github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
+github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
+github.com/NdoleStudio/go-otelroundtripper v0.0.15 h1:lClvnSNRKfdPejUcMaFa3vv9nkIdONJOzO/WSNBVY2w=
+github.com/NdoleStudio/go-otelroundtripper v0.0.15/go.mod h1:YkRryIMC2i4a/6S86isXH+bht3Qp4RRB0rOnnnHBPjU=
+github.com/NdoleStudio/lemonsqueezy-go v1.3.1 h1:lMUVgdAx2onbOUJIVPR05xAANYuCMXBRaGWpAdA4LiM=
+github.com/NdoleStudio/lemonsqueezy-go v1.3.1/go.mod h1:xKRsRX1jSI6mLrVXyWh2sF/1isxTioZrSjWy6HpA3xQ=
+github.com/NdoleStudio/plunk-go v0.0.2 h1:afPW7MHK4Z3rsybpJBnmTmxKCLKF1M7sPI+BNGPf35A=
+github.com/NdoleStudio/plunk-go v0.0.2/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8=
+github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
+github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
-github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
+github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
+github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
+github.com/axiomhq/axiom-go v0.32.0 h1:aRpbqUAn01hY8aJXQftvWHyXfnrNB2KzN5ZquBWvFcE=
+github.com/axiomhq/axiom-go v0.32.0/go.mod h1:3Gmr5M4tINm7Ti00GVfzAduO92Uhd0pghr4ZehIhFxc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
+github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/carlmjohnson/requests v0.25.1 h1:17zNRLecxtAjhtdEIV+F+wrYfe+AGZUjWJtpndcOUYA=
github.com/carlmjohnson/requests v0.25.1/go.mod h1:z3UEf8IE4sZxZ78spW6/tLdqBkfCu1Fn4RaYMnZ8SRM=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
-github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM=
github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg=
-github.com/cncf/xds/go v0.0.0-20251014123835-2ee22ca58382 h1:5IeUoAZvqwF6LcCnV99NbhrGKN6ihZgahJv5jKjmZ3k=
-github.com/cncf/xds/go v0.0.0-20251014123835-2ee22ca58382/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
-github.com/cockroachdb/cockroach-go/v2 v2.4.2 h1:QB0ozDWQUUJ0GP8Zw63X/qHefPTCpLvtfCs6TLrPgyE=
-github.com/cockroachdb/cockroach-go/v2 v2.4.2/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
-github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
-github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
+github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
+github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
-github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
-github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
-github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
-github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
-github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
+github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
-github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
-github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-hermes/hermes/v2 v2.6.2 h1:RuGQlICVtIHixfxtYwN7hAoqGyGxr+D3kE42oE6emcw=
+github.com/go-hermes/hermes/v2 v2.6.2/go.mod h1:RLVNk31/1KqF35vK3mAaQVuJvMH+K5//6OTGJk+j/80=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
-github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
-github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
-github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
-github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
-github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
+github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
+github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
+github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
-github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
-github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
-github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
-github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
-github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
-github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
-github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
-github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
-github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
-github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
-github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
-github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
-github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
-github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
-github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
-github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
+github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
+github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
+github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
+github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
+github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
+github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
+github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
+github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
+github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
+github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
+github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
+github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
+github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
+github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
+github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
+github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
+github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/contrib/otelfiber v1.0.10 h1:Bu28Pi4pfYmGfIc/9+sNaBbFwTHGY/zpSIK5jBxuRtM=
github.com/gofiber/contrib/otelfiber v1.0.10/go.mod h1:jN6AvS1HolDHTQHFURsV+7jSX96FpXYeKH6nmkq8AIw=
-github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
-github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
+github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA=
github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw=
-github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
-github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
-github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
+github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
@@ -176,40 +191,38 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
-github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
-github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
+github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
+github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
+github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
-github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
-github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
+github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hirosassa/zerodriver v0.1.4 h1:8bzamKUOHHq03aEk12qi/lnji2dM+IhFOe+RpKpIZFM=
github.com/hirosassa/zerodriver v0.1.4/go.mod h1:hHOOAQvVGwBV1iVVYujM6vwOBBqQcBIFpJxCD9mJU7Y=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
-github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
+github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
+github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
-github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
-github.com/jaswdr/faker/v2 v2.9.0 h1:Sqqpp+pxduDO+MGOhYE3UHtI9Sowt9j95f8h8nVvips=
-github.com/jaswdr/faker/v2 v2.9.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68=
-github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
-github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
+github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E=
+github.com/jaswdr/faker/v2 v2.9.1/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -222,34 +235,24 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
-github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/matcornic/hermes v1.3.0 h1:k6rih7zpUgfIF/57F3WeBi9n68XkvhC/z8eQTRIsQqc=
-github.com/matcornic/hermes v1.3.0/go.mod h1:X3MXWWBHjKSfgQl0xjv+NQTAGWSiNr/fZTlhAEQJ63Q=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
+github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
+github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -259,20 +262,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
-github.com/nyaruka/phonenumbers v1.6.7 h1:WmebT8TNEzNaui5QlrGqbccRC6dZkEkYc+MGQoILSSo=
-github.com/nyaruka/phonenumbers v1.6.7/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/nyaruka/phonenumbers v1.7.5 h1:xXnwzxyuoRtZOk0VKboO0XqFEpcG7KvrTejcUJOSJBU=
+github.com/nyaruka/phonenumbers v1.7.5/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
+github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
+github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
+github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
+github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
+github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
+github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 h1:nRlQD0u1871kaznCnn1EvYiMbum36v7hw1DLPEjds4o=
github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177/go.mod h1:ao5zGxj8Z4x60IOVYZUbDSmt3R8Ddo080vEgPosHpak=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
-github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
-github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
-github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
-github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
-github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
+github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
+github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
+github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
@@ -282,35 +289,34 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ=
github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8=
-github.com/redis/go-redis/extra/rediscmd/v9 v9.17.2 h1:KYWnHK9pwzOUo3sNJlNmzRwZ5mw7opugn8njtGThKNg=
-github.com/redis/go-redis/extra/rediscmd/v9 v9.17.2/go.mod h1:wsfMQVl/GFYD9Gx/tlxurlTtvHkZRAt8j1qi27eIlTk=
-github.com/redis/go-redis/extra/redisotel/v9 v9.17.2 h1:wthFPRW3Y50CknMrjjJoYwXUFR4U7hMVJCMeLzDI8s4=
-github.com/redis/go-redis/extra/redisotel/v9 v9.17.2/go.mod h1:iqfQX7U2o8MWSl8W+Ah8KqbQyi/UoR/MQNgvaUyA1wc=
-github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
-github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
-github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
-github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
-github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
-github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
-github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/redis/go-redis/extra/rediscmd/v9 v9.20.0 h1:hx/fwTZnY+7hJ3P+zcdFthE6tw/uAEN9TNIZ1CQ7z8Y=
+github.com/redis/go-redis/extra/rediscmd/v9 v9.20.0/go.mod h1:IL+oJIDTKpwgW/ld8CA7ldqO2ACgmbOd2WK9WgPqywY=
+github.com/redis/go-redis/extra/redisotel/v9 v9.20.0 h1:+Bf7EiyYpSyYf5/JBnsHPDR+tBQ4CgRlBt4WV+QPS0k=
+github.com/redis/go-redis/extra/redisotel/v9 v9.20.0/go.mod h1:dRCuOymGRykV8SJVDjvOID00wi2gOthNGg3iLEyrq0A=
+github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
+github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
+github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
+github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
-github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
-github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
-github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -320,120 +326,132 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU=
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
-github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
-github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
-github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
-github.com/uptrace/uptrace-go v1.38.0 h1:QdJfyQkaz7HNPbqM9OkaQ2L9jfdf0DpfZJv9em7YIgE=
-github.com/uptrace/uptrace-go v1.38.0/go.mod h1:SdE9nA+/y+SOIzatuIK2tZeYhoWgrAzAr08kJEquZyM=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
+github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLlyQO7U=
+github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
-github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
+github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
+github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
-github.com/vanng822/go-premailer v1.25.0 h1:hGHKfroCXrCDTyGVR8o4HCON5/HWvc7C1uocS+VnaZs=
-github.com/vanng822/go-premailer v1.25.0/go.mod h1:8WJKIPZtegxqSOA8+eDFx7QNesKmMYfGEIodLTJqrtM=
+github.com/vanng822/go-premailer v1.33.0 h1:nglIpKn/7e3kIAwYByiH5xpauFur7RwAucqyZ59hcic=
+github.com/vanng822/go-premailer v1.33.0/go.mod h1:LGYI7ym6FQ7KcHN16LiQRF+tlan7qwhP1KEhpTINFpo=
+github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
-github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
-github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
+github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
+github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
+github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
+github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
-github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
-github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
+github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
+github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
+github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
+go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib v1.38.0 h1:msaHYZ13HfLIbqXsGwZZQBg5zgxwumlZ1mCkXn3E7LM=
-go.opentelemetry.io/contrib v1.38.0/go.mod h1:4Vp7Az5Dez02V1lCi9OqLvSmSz0lbZu/O2r4XZsqwB0=
-go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
-go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
-go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 h1:PeBoRj6af6xMI7qCupwFvTbbnd49V7n5YpG6pg8iDYQ=
-go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0/go.mod h1:ingqBCtMCe8I4vpz/UVzCW6sxoqgZB37nao91mLQ3Bw=
+go.opentelemetry.io/contrib v1.43.0 h1:rv+pngknCr4qpZDxSpEvEoRioutgfbkk82x6MChJQ3U=
+go.opentelemetry.io/contrib v1.43.0/go.mod h1:JYdNU7Pl/2ckKMGp8/G7zeyhEbtRmy9Q8bcrtv75Znk=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
+go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
+go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e h1:OX282aWfZNOrSVUPF59HlRhyA+MDcyi4kI8WWXt6A8I=
+go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260513205827-ba143fc95a5e/go.mod h1:lw7VQzmNsmkZBRQqOQiREGxO3GtzG/pOVEmKufablmA=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
+go.opentelemetry.io/contrib/processors/minsev v0.16.0 h1:bjTZkvAKnG1mqWgCjU7RkOkHRTMsGlJO/UlqjRCweeU=
+go.opentelemetry.io/contrib/processors/minsev v0.16.0/go.mod h1:R2mmaDsqsWb+Y0mQkPifiCwifdotrG4fFoD4z0tim+g=
go.opentelemetry.io/contrib/propagators/b3 v1.19.0 h1:ulz44cpm6V5oAeg5Aw9HyqGFMS6XM7untlMEhD7YzzA=
go.opentelemetry.io/contrib/propagators/b3 v1.19.0/go.mod h1:OzCmE2IVS+asTI+odXQstRGVfXQ4bXv9nMBRK0nNyqQ=
-go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
-go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
-go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
-go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
-go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
-go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
-go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
+go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
+go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
+go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
+go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/oteltest v1.0.0-RC3 h1:MjaeegZTaX0Bv9uB9CrdVjOFM/8slRjReoWoV9xDCpY=
go.opentelemetry.io/otel/oteltest v1.0.0-RC3/go.mod h1:xpzajI9JBRr7gX63nO6kAmImmYIAtuQblZ36Z+LfCjE=
-go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
-go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
-go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
-go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
-go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
-go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
-go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
-go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
-go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
-go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
-go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
-go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
+go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
+go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
-golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA=
-golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@@ -441,40 +459,33 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
-golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -486,7 +497,6 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -495,45 +505,39 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
-google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
+google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
-google.golang.org/genproto v0.0.0-20251014184007-4626949a642f h1:vLd1CJuJOUgV6qijD7KT5Y2ZtC97ll4dxjTUappMnbo=
-google.golang.org/genproto v0.0.0-20251014184007-4626949a642f/go.mod h1:PI3KrSadr00yqfv6UDvgZGFsmLqeRIwt8x4p5Oo7CdM=
-google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f h1:OiFuztEyBivVKDvguQJYWq1yDcfAHIID/FVrPR4oiI0=
-google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f/go.mod h1:kprOiu9Tr0JYyD6DORrc4Hfyk3RFXqkQ3ctHEum3ZbM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
-google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
+google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
+google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M=
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 07bce2c3..ba0a4f2d 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -4,16 +4,17 @@ import (
"context"
"crypto/tls"
"fmt"
+ "log"
"net/http"
"os"
"strconv"
+ "strings"
"time"
+ "github.com/NdoleStudio/httpsms/docs"
plunk "github.com/NdoleStudio/plunk-go"
"github.com/pusher/pusher-http-go/v5"
- "github.com/NdoleStudio/httpsms/docs"
-
otelMetric "go.opentelemetry.io/otel/metric"
"github.com/dgraph-io/ristretto/v2"
@@ -23,6 +24,7 @@ import (
"github.com/NdoleStudio/httpsms/pkg/discord"
+ "cloud.google.com/go/storage"
mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric"
cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
"github.com/NdoleStudio/httpsms/pkg/cache"
@@ -34,8 +36,6 @@ import (
"github.com/NdoleStudio/go-otelroundtripper"
- "github.com/jinzhu/now"
-
"github.com/uptrace/uptrace-go/uptrace"
"github.com/NdoleStudio/httpsms/pkg/emails"
@@ -43,10 +43,13 @@ import (
cloudtasks "cloud.google.com/go/cloudtasks/apiv2"
"go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
+ "go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
- "firebase.google.com/go/messaging"
+ axiomzerolog "github.com/axiomhq/axiom-go/adapters/zerolog"
"github.com/hirosassa/zerodriver"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/sdk/trace"
@@ -56,6 +59,7 @@ import (
"github.com/NdoleStudio/httpsms/pkg/middlewares"
"google.golang.org/api/option"
+ "github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/NdoleStudio/httpsms/pkg/entities"
@@ -72,28 +76,29 @@ import (
"github.com/NdoleStudio/httpsms/pkg/handlers"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/NdoleStudio/httpsms/pkg/validators"
+ mongoDriver "go.mongodb.org/mongo-driver/v2/mongo"
"gorm.io/driver/postgres"
gormLogger "gorm.io/gorm/logger"
)
// Container is used to resolve services at runtime
type Container struct {
- projectID string
- db *gorm.DB
- dedicatedDB *gorm.DB
- version string
- app *fiber.App
- eventDispatcher *services.EventDispatcher
- logger telemetry.Logger
+ projectID string
+ db *gorm.DB
+ dedicatedDB *gorm.DB
+ mongoDB *mongoDriver.Database
+ version string
+ app *fiber.App
+ eventDispatcher *services.EventDispatcher
+ logger telemetry.Logger
+ attachmentRepository repositories.AttachmentRepository
+ userRistrettoCache *ristretto.Cache[string, entities.AuthContext]
+ phoneRistrettoCache *ristretto.Cache[string, *entities.Phone]
+ inMemoryCache cache.Cache
}
// NewLiteContainer creates a Container without any routes or listeners
func NewLiteContainer() (container *Container) {
- // Set location to UTC
- now.DefaultConfig = &now.Config{
- TimeLocation: time.UTC,
- }
-
return &Container{
logger: logger(3).WithService(fmt.Sprintf("%T", container)),
}
@@ -101,11 +106,6 @@ func NewLiteContainer() (container *Container) {
// NewContainer creates a new dependency injection container
func NewContainer(projectID string, version string) (container *Container) {
- // Set location to UTC
- now.DefaultConfig = &now.Config{
- TimeLocation: time.UTC,
- }
-
container = &Container{
projectID: projectID,
version: version,
@@ -116,6 +116,7 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterMessageListeners()
container.RegisterMessageRoutes()
+ container.RegisterAttachmentRoutes()
container.RegisterBulkMessageRoutes()
container.RegisterMessageThreadRoutes()
@@ -125,9 +126,12 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterHeartbeatListeners()
container.RegisterUserRoutes()
+ container.RegisterMessageSendScheduleRoutes()
+ container.RegisterMessageSendScheduleListeners()
container.RegisterUserListeners()
container.RegisterPhoneRoutes()
+ container.RegisterPhoneListeners()
container.RegisterEventRoutes()
@@ -170,19 +174,30 @@ func (container *Container) App() (app *fiber.App) {
app = fiber.New()
+ // Health check endpoint registered before middleware for reliable Docker health checks
+ app.Get("/health", func(c *fiber.Ctx) error {
+ return c.SendStatus(fiber.StatusOK)
+ })
+
+ app.Use(compress.New(compress.Config{
+ Level: compress.LevelBestCompression,
+ }))
+
if os.Getenv("USE_HTTP_LOGGER") == "true" {
app.Use(fiberLogger.New())
}
app.Use(otelfiber.Middleware())
- app.Use(cors.New(
- cors.Config{
- AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"),
- AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"),
- AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"),
- AllowCredentials: false,
- ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"),
- }),
+ app.Use(
+ cors.New(
+ cors.Config{
+ AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"),
+ AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"),
+ AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"),
+ AllowCredentials: false,
+ ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"),
+ },
+ ),
)
app.Use(middlewares.HTTPRequestLogger(container.Tracer(), container.Logger()))
app.Use(middlewares.BearerAuth(container.Logger(), container.Tracer(), container.FirebaseAuthClient()))
@@ -228,6 +243,10 @@ func (container *Container) GormLogger() gormLogger.Interface {
)
}
+func (container *Container) connect(dsn string, config *gorm.Config) (db *gorm.DB, err error) {
+ return gorm.Open(postgres.Open(dsn), config)
+}
+
// DedicatedDB creates an instance of gorm.DB if it has not been created already
func (container *Container) DedicatedDB() (db *gorm.DB) {
container.logger.Debug(fmt.Sprintf("creating %T", db))
@@ -242,23 +261,21 @@ func (container *Container) DedicatedDB() (db *gorm.DB) {
config = &gorm.Config{Logger: container.GormLogger()}
}
- db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL_DEDICATED")), config)
+ db, err := container.connect(os.Getenv("DATABASE_URL_DEDICATED"), config)
if err != nil {
container.logger.Fatal(err)
}
- sqlDB, err := db.DB()
- if err != nil {
- container.logger.Fatal(stacktrace.Propagate(err, "cannot get sql.DB from GORM"))
- }
-
- sqlDB.SetMaxOpenConns(2)
- sqlDB.SetConnMaxLifetime(time.Hour)
-
if err = db.Use(tracing.NewPlugin()); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin"))
}
+ container.dedicatedDB = db
+ if os.Getenv("DATABASE_MIGRATION_SKIP") != "" {
+ container.logger.Debug(fmt.Sprintf("skipping migrations for [%T]", db))
+ return container.dedicatedDB
+ }
+
container.logger.Debug(fmt.Sprintf("Running migrations for dedicated [%T]", db))
if err = db.AutoMigrate(&entities.Heartbeat{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Heartbeat{})))
@@ -268,10 +285,26 @@ func (container *Container) DedicatedDB() (db *gorm.DB) {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.HeartbeatMonitor{})))
}
- container.dedicatedDB = db
return container.dedicatedDB
}
+// MongoDB creates a *mongo.Database connection to MongoDB Atlas
+func (container *Container) MongoDB() *mongoDriver.Database {
+ if container.mongoDB != nil {
+ return container.mongoDB
+ }
+
+ container.logger.Debug("creating MongoDB *mongo.Database connection")
+
+ db, err := repositories.NewMongoDB(os.Getenv("MONGODB_URI"))
+ if err != nil {
+ container.logger.Fatal(err)
+ }
+
+ container.mongoDB = db
+ return container.mongoDB
+}
+
// DBWithoutMigration creates an instance of gorm.DB if it has not been created already
func (container *Container) DBWithoutMigration() (db *gorm.DB) {
if container.db != nil {
@@ -280,9 +313,9 @@ func (container *Container) DBWithoutMigration() (db *gorm.DB) {
container.logger.Debug(fmt.Sprintf("creating %T", db))
- config := &gorm.Config{TranslateError: true}
- if isLocal() {
- config.Logger = container.GormLogger()
+ config := &gorm.Config{
+ TranslateError: true,
+ Logger: container.GormLogger(),
}
db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), config)
@@ -305,9 +338,9 @@ func (container *Container) DB() (db *gorm.DB) {
container.logger.Debug(fmt.Sprintf("creating %T", db))
- config := &gorm.Config{TranslateError: true}
- if isLocal() {
- config.Logger = container.GormLogger()
+ config := &gorm.Config{
+ TranslateError: true,
+ Logger: container.GormLogger(),
}
db, err := gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), config)
@@ -320,13 +353,21 @@ func (container *Container) DB() (db *gorm.DB) {
container.logger.Fatal(stacktrace.Propagate(err, "cannot use GORM tracing plugin"))
}
+ if os.Getenv("DATABASE_MIGRATION_SKIP") != "" {
+ container.logger.Debug(fmt.Sprintf("skipping migrations for [%T]", db))
+ return container.db
+ }
+
container.logger.Debug(fmt.Sprintf("Running migrations for %T", db))
// This prevents a bug in the Gorm AutoMigrate where it tries to delete this no existent constraints
- db.Exec(`
+ // This is only applicable to PROD on cockroachDB
+ if os.Getenv("DATABASE_MIGRATION_CONSTRAINT_FIX") == "1" {
+ db.Exec(`
ALTER TABLE users ADD CONSTRAINT IF NOT EXISTS uni_users_api_key CHECK (api_key IS NOT NULL);
ALTER TABLE phone_api_keys ADD CONSTRAINT IF NOT EXISTS uni_phone_api_keys_api_key CHECK (api_key IS NOT NULL);
ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (server_id IS NOT NULL);`)
+ }
if err = db.AutoMigrate(&entities.Message{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Message{})))
@@ -340,6 +381,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.User{})))
}
+ if err = db.AutoMigrate(&entities.MessageSendSchedule{}); err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{})))
+ }
+
if err = db.AutoMigrate(&entities.Phone{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{})))
}
@@ -374,6 +419,7 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
// FirebaseApp creates a new instance of firebase.App
func (container *Container) FirebaseApp() (app *firebase.App) {
container.logger.Debug(fmt.Sprintf("creating %T", app))
+
app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
if err != nil {
msg := "cannot initialize firebase application"
@@ -382,11 +428,15 @@ func (container *Container) FirebaseApp() (app *firebase.App) {
return app
}
-// InMemoryCache creates a new instance of the in memory cache.Cache
+// InMemoryCache returns the shared in-memory cache.Cache, creating it on the first call.
func (container *Container) InMemoryCache() cache.Cache {
+ if container.inMemoryCache != nil {
+ return container.inMemoryCache
+ }
container.logger.Debug("creating an in memory cache")
c := ttlCache.New(time.Hour, time.Hour*2)
- return cache.NewMemoryCache(container.Tracer(), c)
+ container.inMemoryCache = cache.NewMemoryCache(container.Tracer(), c)
+ return container.inMemoryCache
}
// Cache creates a new instance of cache.Cache
@@ -396,8 +446,10 @@ func (container *Container) Cache() cache.Cache {
if err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL"))))
}
- opt.TLSConfig = &tls.Config{
- MinVersion: tls.VersionTLS12,
+ if strings.HasPrefix(os.Getenv("REDIS_URL"), "rediss://") {
+ opt.TLSConfig = &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ }
}
redisClient := redis.NewClient(opt)
@@ -483,15 +535,27 @@ func (container *Container) CloudTaskEventsQueue() (queue services.PushQueue) {
)
}
-// FirebaseMessagingClient creates a new instance of messaging.Client
-func (container *Container) FirebaseMessagingClient() (client *messaging.Client) {
- container.logger.Debug(fmt.Sprintf("creating %T", client))
+// FCMClient creates the appropriate FCM client based on configuration.
+// When FCM_ENDPOINT is set, it returns an EmulatorFCMClient that sends
+// notifications directly to the phone emulator via HTTP.
+// Otherwise, it returns a FirebaseFCMClient that uses the real Firebase SDK.
+func (container *Container) FCMClient() services.FCMClient {
+ if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" {
+ container.logger.Info(fmt.Sprintf("using emulator FCM client with endpoint: %s", fcmEndpoint))
+ return services.NewEmulatorFCMClient(
+ container.HTTPClient("emulator_fcm"),
+ fcmEndpoint,
+ container.Logger(),
+ )
+ }
+
+ container.logger.Debug("creating FirebaseFCMClient")
messagingClient, err := container.FirebaseApp().Messaging(context.Background())
if err != nil {
msg := "cannot initialize firebase messaging client"
container.logger.Fatal(stacktrace.Propagate(err, msg))
}
- return messagingClient
+ return services.NewFirebaseFCMClient(messagingClient)
}
// FirebaseCredentials returns firebase credentials as bytes.
@@ -517,6 +581,7 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes
container.Tracer(),
container.PhoneService(),
container.TurnstileTokenValidator(),
+ container.Cache(),
)
}
@@ -539,6 +604,7 @@ func (container *Container) BulkMessageHandlerValidator() (validator *validators
container.Tracer(),
container.PhoneService(),
container.UserService(),
+ container.Cache(),
)
}
@@ -639,6 +705,7 @@ func (container *Container) PhoneHandlerValidator() (validator *validators.Phone
return validators.NewPhoneHandlerValidator(
container.Logger(),
container.Tracer(),
+ container.MessageSendScheduleService(),
)
}
@@ -648,6 +715,7 @@ func (container *Container) UserHandlerValidator() (validator *validators.UserHa
return validators.NewUserHandlerValidator(
container.Logger(),
container.Tracer(),
+ container.UserService(),
)
}
@@ -726,6 +794,48 @@ func (container *Container) PhoneRepository() (repository repositories.PhoneRepo
)
}
+// MessageSendScheduleRepository creates a new instance of repositories.MessageSendScheduleRepository
+func (container *Container) MessageSendScheduleRepository() repositories.MessageSendScheduleRepository {
+ container.logger.Debug("creating GORM repositories.MessageSendScheduleRepository")
+ return repositories.NewGormMessageSendScheduleRepository(
+ container.Logger(),
+ container.Tracer(),
+ container.DB(),
+ )
+}
+
+// MessageSendScheduleService creates a new instance of services.MessageSendScheduleService
+func (container *Container) MessageSendScheduleService() *services.MessageSendScheduleService {
+ container.logger.Debug("creating services.MessageSendScheduleService")
+ return services.NewMessageSendScheduleService(
+ container.Logger(),
+ container.Tracer(),
+ container.MessageSendScheduleRepository(),
+ container.EventDispatcher(),
+ )
+}
+
+// MessageSendScheduleHandlerValidator creates a new instance of validators.MessageSendScheduleHandlerValidator
+func (container *Container) MessageSendScheduleHandlerValidator() *validators.MessageSendScheduleHandlerValidator {
+ container.logger.Debug("creating validators.MessageSendScheduleHandlerValidator")
+ return validators.NewMessageSendScheduleHandlerValidator(
+ container.Logger(),
+ container.Tracer(),
+ )
+}
+
+// MessageSendScheduleHandler creates a new instance of handlers.MessageSendScheduleHandler
+func (container *Container) MessageSendScheduleHandler() *handlers.MessageSendScheduleHandler {
+ container.logger.Debug("creating handlers.MessageSendScheduleHandler")
+ return handlers.NewMessageSendScheduleHandler(
+ container.Logger(),
+ container.Tracer(),
+ container.MessageSendScheduleHandlerValidator(),
+ container.MessageSendScheduleService(),
+ container.EntitlementService(),
+ )
+}
+
// BillingUsageRepository creates a new instance of repositories.BillingUsageRepository
func (container *Container) BillingUsageRepository() (repository repositories.BillingUsageRepository) {
container.logger.Debug("creating GORM repositories.BillingUsageRepository")
@@ -736,6 +846,17 @@ func (container *Container) BillingUsageRepository() (repository repositories.Bi
)
}
+// EntitlementService creates a new instance of services.EntitlementService
+func (container *Container) EntitlementService() *services.EntitlementService {
+ container.logger.Debug("creating services.EntitlementService")
+ return services.NewEntitlementService(
+ container.Logger(),
+ container.Tracer(),
+ os.Getenv("ENTITLEMENT_ENABLED") == "true",
+ container.UserRepository(),
+ )
+}
+
// DiscordRepository creates a new instance of repositories.DiscordRepository
func (container *Container) DiscordRepository() (repository repositories.DiscordRepository) {
container.logger.Debug("creating GORM repositories.DiscordRepository")
@@ -778,12 +899,22 @@ func (container *Container) MessageThreadRepository() (repository repositories.M
// HeartbeatMonitorRepository creates a new instance of repositories.HeartbeatMonitorRepository
func (container *Container) HeartbeatMonitorRepository() (repository repositories.HeartbeatMonitorRepository) {
- container.logger.Debug("creating GORM repositories.HeartbeatMonitorRepository")
- return repositories.NewGormHeartbeatMonitorRepository(
- container.Logger(),
- container.Tracer(),
- container.DedicatedDB(),
- )
+ switch os.Getenv("HEARTBEAT_DB_BACKEND") {
+ case "mongodb":
+ container.logger.Debug("creating MongoDB repositories.HeartbeatMonitorRepository")
+ return repositories.NewMongoHeartbeatMonitorRepository(
+ container.Logger(),
+ container.Tracer(),
+ container.MongoDB(),
+ )
+ default:
+ container.logger.Debug("creating GORM repositories.HeartbeatMonitorRepository")
+ return repositories.NewGormHeartbeatMonitorRepository(
+ container.Logger(),
+ container.Tracer(),
+ container.DedicatedDB(),
+ )
+ }
}
// HeartbeatService creates a new instance of services.HeartbeatService
@@ -866,7 +997,6 @@ func (container *Container) HTTPRoundTripper(name string) http.RoundTripper {
otelroundtripper.WithName(name),
otelroundtripper.WithParent(container.RetryHTTPRoundTripper()),
otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)),
- otelroundtripper.WithAttributes(container.OtelResources(container.version, container.projectID).Attributes()...),
)
}
@@ -876,7 +1006,6 @@ func (container *Container) HTTPRoundTripperWithoutRetry(name string) http.Round
return otelroundtripper.New(
otelroundtripper.WithName(name),
otelroundtripper.WithMeter(otel.GetMeterProvider().Meter(container.projectID)),
- otelroundtripper.WithAttributes(container.OtelResources(container.version, container.projectID).Attributes()...),
)
}
@@ -887,6 +1016,7 @@ func (container *Container) OtelResources(version string, namespace string) *res
semconv.ServiceNameKey.String(namespace),
semconv.ServiceVersionKey.String(version),
semconv.ServiceInstanceIDKey.String(hostName()),
+ semconv.HostNameKey.String(hostName()),
semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")),
)
}
@@ -934,6 +1064,7 @@ func (container *Container) UserService() (service *services.UserService) {
container.LemonsqueezyClient(),
container.EventDispatcher(),
container.FirebaseAuthClient(),
+ container.HTTPClient("lemonsqueezy"),
)
}
@@ -1069,6 +1200,20 @@ func (container *Container) RegisterMessageListeners() {
}
}
+// RegisterMessageSendScheduleListeners registers event listeners for listeners.MessageSendScheduleListener
+func (container *Container) RegisterMessageSendScheduleListeners() {
+ container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.MessageSendScheduleListener{}))
+ _, routes := listeners.NewMessageSendScheduleListener(
+ container.Logger(),
+ container.Tracer(),
+ container.MessageSendScheduleService(),
+ )
+
+ for event, handler := range routes {
+ container.EventDispatcher().Subscribe(event, handler)
+ }
+}
+
// LemonsqueezyService creates a new instance of services.LemonsqueezyService
func (container *Container) LemonsqueezyService() (service *services.LemonsqueezyService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
@@ -1113,6 +1258,7 @@ func (container *Container) PhoneAPIKeyHandler() (handler *handlers.PhoneAPIKeyH
container.Tracer(),
container.PhoneAPIKeyHandlerValidator(),
container.PhoneAPIKeyService(),
+ container.EntitlementService(),
)
}
@@ -1362,6 +1508,20 @@ func (container *Container) RegisterPhoneAPIKeyListeners() {
}
}
+// RegisterPhoneListeners registers event listeners for listeners.PhoneListener
+func (container *Container) RegisterPhoneListeners() {
+ container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.PhoneListener{}))
+ _, routes := listeners.NewPhoneListener(
+ container.Logger(),
+ container.Tracer(),
+ container.PhoneService(),
+ )
+
+ for event, handler := range routes {
+ container.EventDispatcher().Subscribe(event, handler)
+ }
+}
+
// RegisterWebsocketListeners registers event listeners for listeners.WebsocketListener
func (container *Container) RegisterWebsocketListeners() {
container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebsocketListener{}))
@@ -1405,9 +1565,63 @@ func (container *Container) MessageService() (service *services.MessageService)
container.MessageRepository(),
container.EventDispatcher(),
container.PhoneService(),
+ container.AttachmentRepository(),
+ container.APIBaseURL(),
+ )
+}
+
+// AttachmentRepository creates a cached AttachmentRepository based on configuration
+func (container *Container) AttachmentRepository() repositories.AttachmentRepository {
+ if container.attachmentRepository != nil {
+ return container.attachmentRepository
+ }
+
+ bucket := os.Getenv("GCS_BUCKET_NAME")
+ if bucket != "" {
+ container.logger.Debug("creating GoogleCloudStorageAttachmentRepository")
+ client, err := storage.NewClient(context.Background(), option.WithAuthCredentialsJSON(option.ServiceAccount, container.FirebaseCredentials()))
+ if err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create GCS client"))
+ }
+ container.attachmentRepository = repositories.NewGoogleCloudStorageAttachmentRepository(
+ container.Logger(),
+ container.Tracer(),
+ client,
+ bucket,
+ )
+ } else {
+ container.logger.Debug("creating MemoryAttachmentRepository (GCS_BUCKET_NAME not set)")
+ container.attachmentRepository = repositories.NewMemoryAttachmentRepository(
+ container.Logger(),
+ container.Tracer(),
+ )
+ }
+
+ return container.attachmentRepository
+}
+
+// APIBaseURL returns the API base URL derived from EVENTS_QUEUE_ENDPOINT
+func (container *Container) APIBaseURL() string {
+ endpoint := os.Getenv("EVENTS_QUEUE_ENDPOINT")
+ return strings.TrimSuffix(endpoint, "/v1/events")
+}
+
+// AttachmentHandler creates a new AttachmentHandler
+func (container *Container) AttachmentHandler() (handler *handlers.AttachmentHandler) {
+ container.logger.Debug(fmt.Sprintf("creating %T", handler))
+ return handlers.NewAttachmentHandler(
+ container.Logger(),
+ container.Tracer(),
+ container.AttachmentRepository(),
)
}
+// RegisterAttachmentRoutes registers routes for the /attachments prefix
+func (container *Container) RegisterAttachmentRoutes() {
+ container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.AttachmentHandler{}))
+ container.AttachmentHandler().RegisterRoutes(container.App())
+}
+
// PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService
func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) {
container.logger.Debug(fmt.Sprintf("creating %T", service))
@@ -1425,9 +1639,10 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
return services.NewNotificationService(
container.Logger(),
container.Tracer(),
- container.FirebaseMessagingClient(),
+ container.FCMClient(),
container.PhoneRepository(),
container.PhoneNotificationRepository(),
+ container.MessageSendScheduleRepository(),
container.EventDispatcher(),
)
}
@@ -1435,8 +1650,8 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
// RegisterMessageRoutes registers routes for the /messages prefix
func (container *Container) RegisterMessageRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageHandler{}))
- container.MessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
container.MessageHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware())
+ container.MessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}
// RegisterBulkMessageRoutes registers routes for the /bulk-messages prefix
@@ -1483,6 +1698,12 @@ func (container *Container) RegisterUserRoutes() {
container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
}
+// RegisterMessageSendScheduleRoutes registers routes for the /send-schedules prefix
+func (container *Container) RegisterMessageSendScheduleRoutes() {
+ container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageSendScheduleHandler{}))
+ container.MessageSendScheduleHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
+}
+
// RegisterEventRoutes registers routes for the /events prefix
func (container *Container) RegisterEventRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{}))
@@ -1507,12 +1728,22 @@ func (container *Container) RegisterSwaggerRoutes() {
// HeartbeatRepository registers a new instance of repositories.HeartbeatRepository
func (container *Container) HeartbeatRepository() repositories.HeartbeatRepository {
- container.logger.Debug("creating GORM repositories.HeartbeatRepository")
- return repositories.NewGormHeartbeatRepository(
- container.Logger(),
- container.Tracer(),
- container.DedicatedDB(),
- )
+ switch os.Getenv("HEARTBEAT_DB_BACKEND") {
+ case "mongodb":
+ container.logger.Debug("creating MongoDB repositories.HeartbeatRepository")
+ return repositories.NewMongoHeartbeatRepository(
+ container.Logger(),
+ container.Tracer(),
+ container.MongoDB(),
+ )
+ default:
+ container.logger.Debug("creating GORM repositories.HeartbeatRepository")
+ return repositories.NewGormHeartbeatRepository(
+ container.Logger(),
+ container.Tracer(),
+ container.DedicatedDB(),
+ )
+ }
}
// UserRepository registers a new instance of repositories.UserRepository
@@ -1527,22 +1758,29 @@ func (container *Container) UserRepository() repositories.UserRepository {
}
// PhoneRistrettoCache creates an in-memory *ristretto.Cache[string, *entities.Phone]
-func (container *Container) PhoneRistrettoCache() (cache *ristretto.Cache[string, *entities.Phone]) {
- container.logger.Debug(fmt.Sprintf("creating %T", cache))
+func (container *Container) PhoneRistrettoCache() *ristretto.Cache[string, *entities.Phone] {
+ if container.phoneRistrettoCache != nil {
+ return container.phoneRistrettoCache
+ }
+ container.logger.Debug(fmt.Sprintf("creating %T", container.phoneRistrettoCache))
ristrettoCache, err := ristretto.NewCache[string, *entities.Phone](&ristretto.Config[string, *entities.Phone]{
MaxCost: 5000,
NumCounters: 5000 * 10,
BufferItems: 64,
})
if err != nil {
- container.logger.Fatal(stacktrace.Propagate(err, "cannot create user ristretto cache"))
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create phone ristretto cache"))
}
- return ristrettoCache
+ container.phoneRistrettoCache = ristrettoCache
+ return container.phoneRistrettoCache
}
// UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthContext]
-func (container *Container) UserRistrettoCache() (cache *ristretto.Cache[string, entities.AuthContext]) {
- container.logger.Debug(fmt.Sprintf("creating %T", cache))
+func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entities.AuthContext] {
+ if container.userRistrettoCache != nil {
+ return container.userRistrettoCache
+ }
+ container.logger.Debug(fmt.Sprintf("creating %T", container.userRistrettoCache))
ristrettoCache, err := ristretto.NewCache[string, entities.AuthContext](&ristretto.Config[string, entities.AuthContext]{
MaxCost: 5000,
NumCounters: 5000 * 10,
@@ -1551,12 +1789,13 @@ func (container *Container) UserRistrettoCache() (cache *ristretto.Cache[string,
if err != nil {
container.logger.Fatal(stacktrace.Propagate(err, "cannot create user ristretto cache"))
}
+ container.userRistrettoCache = ristrettoCache
return ristrettoCache
}
// InitializeTraceProvider initializes the open telemetry trace provider
func (container *Container) InitializeTraceProvider() func() {
- return container.initializeUptraceProvider(container.version, container.projectID)
+ return container.initializeAxiomTraceProvider(container.version, container.projectID)
}
func (container *Container) initializeGoogleTraceProvider(version string, namespace string) func() {
@@ -1595,6 +1834,65 @@ func (container *Container) initializeGoogleTraceProvider(version string, namesp
}
}
+func (container *Container) initializeAxiomTraceProvider(version string, namespace string) func() {
+ container.logger.Debug("initializing axiom trace provider")
+
+ traceHeaders := map[string]string{
+ "Authorization": "Bearer " + os.Getenv("AXIOM_TOKEN"),
+ "X-Axiom-Dataset": os.Getenv("AXIOM_DATASET_EVENTS"),
+ }
+
+ traceExporter, err := otlptracehttp.New(
+ context.Background(),
+ otlptracehttp.WithEndpoint("us-east-1.aws.edge.axiom.co"),
+ otlptracehttp.WithHeaders(traceHeaders),
+ )
+ if err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create axiom OTLP trace exporter"))
+ }
+
+ tp := trace.NewTracerProvider(
+ trace.WithBatcher(traceExporter),
+ trace.WithSampler(trace.AlwaysSample()),
+ trace.WithResource(container.OtelResources(version, namespace)),
+ )
+ otel.SetTracerProvider(tp)
+
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
+ propagation.TraceContext{},
+ propagation.Baggage{},
+ ))
+
+ metricHeaders := map[string]string{
+ "Authorization": "Bearer " + os.Getenv("AXIOM_TOKEN"),
+ "X-Axiom-Dataset": os.Getenv("AXIOM_DATASET_METRICS"),
+ }
+
+ metricExporter, err := otlpmetrichttp.New(
+ context.Background(),
+ otlpmetrichttp.WithEndpoint("us-east-1.aws.edge.axiom.co"),
+ otlpmetrichttp.WithHeaders(metricHeaders),
+ )
+ if err != nil {
+ container.logger.Fatal(stacktrace.Propagate(err, "cannot create axiom OTLP metric exporter"))
+ }
+
+ meterProvider := metric.NewMeterProvider(
+ metric.WithReader(metric.NewPeriodicReader(metricExporter)),
+ metric.WithResource(container.OtelResources(version, namespace)),
+ )
+ otel.SetMeterProvider(meterProvider)
+
+ return func() {
+ if err := tp.Shutdown(context.Background()); err != nil {
+ container.logger.Error(stacktrace.Propagate(err, "cannot shutdown axiom trace provider"))
+ }
+ if err := meterProvider.Shutdown(context.Background()); err != nil {
+ container.logger.Error(stacktrace.Propagate(err, "cannot shutdown axiom meter provider"))
+ }
+ }
+}
+
func (container *Container) initializeUptraceProvider(version string, namespace string) (flush func()) {
container.logger.Debug("initializing uptrace provider")
// Configure OpenTelemetry with sensible defaults.
@@ -1616,8 +1914,8 @@ func (container *Container) initializeUptraceProvider(version string, namespace
func logger(skipFrameCount int) telemetry.Logger {
fields := map[string]string{
- "pid": strconv.Itoa(os.Getpid()),
- "hostname": hostName(),
+ "hostname": hostName(),
+ string(semconv.DeploymentEnvironmentKey): os.Getenv("ENV"),
}
return telemetry.NewZerologLogger(
@@ -1632,7 +1930,7 @@ func logDriver(skipFrameCount int) *zerodriver.Logger {
if isLocal() {
return consoleLogger(skipFrameCount)
}
- return jsonLogger(skipFrameCount)
+ return axiomLogger(skipFrameCount)
}
func jsonLogger(skipFrameCount int) *zerodriver.Logger {
@@ -1661,6 +1959,19 @@ func jsonLogger(skipFrameCount int) *zerodriver.Logger {
return &zerodriver.Logger{Logger: &zl}
}
+func axiomLogger(skipFrameCount int) *zerodriver.Logger {
+ axiomWriter, err := axiomzerolog.New(
+ axiomzerolog.SetLevels([]zerolog.Level{zerolog.TraceLevel, zerolog.DebugLevel, zerolog.InfoLevel, zerolog.WarnLevel, zerolog.ErrorLevel, zerolog.PanicLevel, zerolog.FatalLevel, zerolog.NoLevel}),
+ axiomzerolog.SetDataset(os.Getenv("AXIOM_DATASET_EVENTS")),
+ )
+ if err != nil {
+ log.Fatal(stacktrace.Propagate(err, "cannot create axiom zerolog writer"))
+ }
+
+ zl := zerolog.New(axiomWriter).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger()
+ return &zerodriver.Logger{Logger: &zl}
+}
+
func hostName() string {
h, err := os.Hostname()
if err != nil {
@@ -1673,7 +1984,8 @@ func consoleLogger(skipFrameCount int) *zerodriver.Logger {
l := zerolog.New(
zerolog.ConsoleWriter{
Out: os.Stderr,
- }).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger()
+ },
+ ).With().Timestamp().CallerWithSkipFrameCount(skipFrameCount).Logger()
return &zerodriver.Logger{
Logger: &l,
}
diff --git a/api/pkg/emails/hermes_mailer.go b/api/pkg/emails/hermes_mailer.go
index 0afce49d..7efe0f8a 100644
--- a/api/pkg/emails/hermes_mailer.go
+++ b/api/pkg/emails/hermes_mailer.go
@@ -5,7 +5,7 @@ import (
"strconv"
"time"
- "github.com/matcornic/hermes"
+ "github.com/go-hermes/hermes/v2"
)
// HermesGeneratorConfig contains details for the generator
diff --git a/api/pkg/emails/hermes_notification_email_factory.go b/api/pkg/emails/hermes_notification_email_factory.go
index 0447997d..7c4b7bc5 100644
--- a/api/pkg/emails/hermes_notification_email_factory.go
+++ b/api/pkg/emails/hermes_notification_email_factory.go
@@ -7,7 +7,7 @@ import (
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/httpsms/pkg/entities"
- "github.com/matcornic/hermes"
+ "github.com/go-hermes/hermes/v2"
"github.com/palantir/stacktrace"
)
@@ -33,11 +33,11 @@ func (factory *hermesNotificationEmailFactory) DiscordSendFailed(user *entities.
fmt.Sprintf("We ran into an error while fowarding an incoming SMS to your discord server at %s", user.UserTimeString(time.Now())),
},
Dictionary: []hermes.Entry{
- {"Discord Channel ID", payload.DiscordChannelID},
- {"Event Name", payload.EventType},
- {"Phone Number", factory.formatPhoneNumber(payload.Owner)},
- {"HTTP Response Code", factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)},
- {"Error Message / HTTP Response", payload.ErrorMessage},
+ {Key: "Discord Channel ID", Value: payload.DiscordChannelID},
+ {Key: "Event Name", Value: payload.EventType},
+ {Key: "Phone Number", Value: factory.formatPhoneNumber(payload.Owner)},
+ {Key: "HTTP Response Code", Value: factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)},
+ {Key: "Error Message / HTTP Response", Value: payload.ErrorMessage},
},
Actions: []hermes.Action{
{
@@ -83,13 +83,13 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
fmt.Sprintf("We ran into an error while fowarding a webhook event from httpSMS to your webserver at %s", user.UserTimeString(time.Now())),
},
Dictionary: []hermes.Entry{
- {"Server URL", payload.WebhookURL},
- {"Event Name", payload.EventType},
- {"Event ID", payload.EventID},
- {"Phone Number", factory.formatPhoneNumber(payload.Owner)},
- {"HTTP Response Code", factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)},
- {"Error Message / HTTP Response", payload.ErrorMessage},
- {"Event Payload", payload.EventPayload},
+ {Key: "Server URL", Value: payload.WebhookURL},
+ {Key: "Event Name", Value: payload.EventType},
+ {Key: "Event ID", Value: payload.EventID},
+ {Key: "Phone Number", Value: factory.formatPhoneNumber(payload.Owner)},
+ {Key: "HTTP Response Code", Value: factory.formatHTTPResponseCode(payload.HTTPResponseStatusCode)},
+ {Key: "Error Message / HTTP Response", Value: payload.ErrorMessage},
+ {Key: "Event Payload", Value: payload.EventPayload},
},
Actions: []hermes.Action{
{
@@ -135,11 +135,11 @@ func (factory *hermesNotificationEmailFactory) MessageExpired(user *entities.Use
fmt.Sprintf("The SMS message which you sent to %s has expired at %s and you will need to resend this message.", factory.formatPhoneNumber(payload.Contact), user.UserTimeString(time.Now())),
},
Dictionary: []hermes.Entry{
- {"ID", payload.MessageID.String()},
- {"From", factory.formatPhoneNumber(payload.Owner)},
- {"To", factory.formatPhoneNumber(payload.Contact)},
- {"Message", payload.Content},
- {"Encrypted", factory.formatBool(payload.Encrypted)},
+ {Key: "ID", Value: payload.MessageID.String()},
+ {Key: "From", Value: factory.formatPhoneNumber(payload.Owner)},
+ {Key: "To", Value: factory.formatPhoneNumber(payload.Contact)},
+ {Key: "Message", Value: payload.Content},
+ {Key: "Encrypted", Value: factory.formatBool(payload.Encrypted)},
},
Actions: []hermes.Action{
{
@@ -185,12 +185,12 @@ func (factory *hermesNotificationEmailFactory) MessageFailed(user *entities.User
fmt.Sprintf("The SMS message which you sent to %s has failed at %s and you will need to resend this message.", factory.formatPhoneNumber(payload.Contact), user.UserTimeString(time.Now())),
},
Dictionary: []hermes.Entry{
- {"ID", payload.ID.String()},
- {"From", factory.formatPhoneNumber(payload.Owner)},
- {"To", factory.formatPhoneNumber(payload.Contact)},
- {"Message", payload.Content},
- {"Encrypted", factory.formatBool(payload.Encrypted)},
- {"Failure Reason", payload.ErrorMessage},
+ {Key: "ID", Value: payload.ID.String()},
+ {Key: "From", Value: factory.formatPhoneNumber(payload.Owner)},
+ {Key: "To", Value: factory.formatPhoneNumber(payload.Contact)},
+ {Key: "Message", Value: payload.Content},
+ {Key: "Encrypted", Value: factory.formatBool(payload.Encrypted)},
+ {Key: "Failure Reason", Value: payload.ErrorMessage},
},
Actions: []hermes.Action{
{
diff --git a/api/pkg/emails/hermes_theme.go b/api/pkg/emails/hermes_theme.go
index 56b49759..9d8cc471 100644
--- a/api/pkg/emails/hermes_theme.go
+++ b/api/pkg/emails/hermes_theme.go
@@ -1,10 +1,14 @@
package emails
-import "github.com/matcornic/hermes"
+import "github.com/go-hermes/hermes/v2"
// hermesTheme is the theme by default
type hermesTheme struct{}
+func (dt *hermesTheme) Styles() hermes.StylesDefinition {
+ return hermes.Default{}.Styles()
+}
+
func newHermesTheme() hermes.Theme {
return &hermesTheme{}
}
diff --git a/api/pkg/emails/hermes_user_email_factory.go b/api/pkg/emails/hermes_user_email_factory.go
index 3d50f6cc..9ec5754a 100644
--- a/api/pkg/emails/hermes_user_email_factory.go
+++ b/api/pkg/emails/hermes_user_email_factory.go
@@ -5,7 +5,7 @@ import (
"time"
"github.com/NdoleStudio/httpsms/pkg/entities"
- "github.com/matcornic/hermes"
+ "github.com/go-hermes/hermes/v2"
"github.com/palantir/stacktrace"
)
diff --git a/api/pkg/entities/bulk_message.go b/api/pkg/entities/bulk_message.go
new file mode 100644
index 00000000..86227ffa
--- /dev/null
+++ b/api/pkg/entities/bulk_message.go
@@ -0,0 +1,16 @@
+package entities
+
+import "time"
+
+// BulkMessage represents a summary of a bulk message batch
+type BulkMessage struct {
+ RequestID string `json:"request_id" example:"bulk-csv-a1B2c3D4e5"`
+ Total int64 `json:"total" example:"150"`
+ ScheduledCount int64 `json:"scheduled_count" example:"50"`
+ PendingCount int64 `json:"pending_count" example:"30"`
+ FailedCount int64 `json:"failed_count" example:"5"`
+ ExpiredCount int64 `json:"expired_count" example:"3"`
+ SentCount int64 `json:"sent_count" example:"40"`
+ DeliveredCount int64 `json:"delivered_count" example:"25"`
+ CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
+}
diff --git a/api/pkg/entities/heartbeat.go b/api/pkg/entities/heartbeat.go
index 629efd29..abc070e9 100644
--- a/api/pkg/entities/heartbeat.go
+++ b/api/pkg/entities/heartbeat.go
@@ -8,10 +8,10 @@ import (
// Heartbeat represents is a pulse from an active phone
type Heartbeat struct {
- ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
- Owner string `json:"owner" gorm:"index:idx_heartbeats_owner_timestamp" example:"+18005550199"`
- Version string `json:"version" example:"344c10f"`
- Charging bool `json:"charging" example:"true"`
- UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
- Timestamp time.Time `json:"timestamp" gorm:"index:idx_heartbeats_owner_timestamp" example:"2022-06-05T14:26:01.520828+03:00"`
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" bson:"_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ Owner string `json:"owner" gorm:"index:idx_heartbeats_owner_timestamp" bson:"owner" example:"+18005550199"`
+ Version string `json:"version" bson:"version" example:"344c10f"`
+ Charging bool `json:"charging" bson:"charging" example:"true"`
+ UserID UserID `json:"user_id" bson:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
+ Timestamp time.Time `json:"timestamp" gorm:"index:idx_heartbeats_owner_timestamp" bson:"timestamp" example:"2022-06-05T14:26:01.520828+03:00"`
}
diff --git a/api/pkg/entities/heartbeat_monitor.go b/api/pkg/entities/heartbeat_monitor.go
index 6b41a31a..7151f195 100644
--- a/api/pkg/entities/heartbeat_monitor.go
+++ b/api/pkg/entities/heartbeat_monitor.go
@@ -8,14 +8,14 @@ import (
// HeartbeatMonitor is used to monitor heartbeats of a phone
type HeartbeatMonitor struct {
- ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
- PhoneID uuid.UUID `json:"phone_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
- UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
- QueueID string `json:"queue_id" example:"0360259236613675274"`
- Owner string `json:"owner" example:"+18005550199"`
- PhoneOnline bool `json:"phone_online" example:"true" default:"true"`
- CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
- UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" bson:"_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ PhoneID uuid.UUID `json:"phone_id" bson:"phone_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ UserID UserID `json:"user_id" bson:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
+ QueueID string `json:"queue_id" bson:"queue_id" example:"0360259236613675274"`
+ Owner string `json:"owner" bson:"owner" example:"+18005550199"`
+ PhoneOnline bool `json:"phone_online" bson:"phone_online" example:"true" default:"true"`
+ CreatedAt time.Time `json:"created_at" bson:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
+ UpdatedAt time.Time `json:"updated_at" bson:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
}
// RequiresCheck returns true if the heartbeat monitor requires a check
diff --git a/api/pkg/entities/message.go b/api/pkg/entities/message.go
index bf846c0b..52a9a221 100644
--- a/api/pkg/entities/message.go
+++ b/api/pkg/entities/message.go
@@ -4,6 +4,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/lib/pq"
)
// MessageType is the type of message if it is incoming or outgoing
@@ -83,15 +84,16 @@ func (s SIM) String() string {
// Message represents a message sent between 2 phone numbers
type Message struct {
- ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
- RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
- Owner string `json:"owner" example:"+18005550199"`
- UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
- Contact string `json:"contact" example:"+18005550100"`
- Content string `json:"content" example:"This is a sample text message"`
- Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"`
- Type MessageType `json:"type" example:"mobile-terminated"`
- Status MessageStatus `json:"status" example:"pending"`
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
+ Owner string `json:"owner" example:"+18005550199"`
+ UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
+ Contact string `json:"contact" example:"+18005550100"`
+ Content string `json:"content" example:"This is a sample text message"`
+ Attachments pq.StringArray `json:"attachments" gorm:"type:text[]" swaggertype:"array,string" example:"https://example.com/image.jpg,https://example.com/video.mp4"`
+ Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"`
+ Type MessageType `json:"type" example:"mobile-terminated"`
+ Status MessageStatus `json:"status" example:"pending"`
// SIM is the SIM card to use to send the message
// * SMS1: use the SIM card in slot 1
// * SMS2: use the SIM card in slot 2
diff --git a/api/pkg/entities/message_send_schedule.go b/api/pkg/entities/message_send_schedule.go
new file mode 100644
index 00000000..7be7dc2b
--- /dev/null
+++ b/api/pkg/entities/message_send_schedule.go
@@ -0,0 +1,90 @@
+package entities
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// EntityNameMessageSendSchedule is the entitlement entity name for message send schedules.
+const EntityNameMessageSendSchedule = "MessageSendSchedule"
+
+// MessageSendScheduleWindow represents a single availability window for a day of the week.
+type MessageSendScheduleWindow struct {
+ DayOfWeek int `json:"day_of_week" example:"1"`
+ StartMinute int `json:"start_minute" example:"540"`
+ EndMinute int `json:"end_minute" example:"1020"`
+}
+
+// MessageSendSchedule controls when a phone is allowed to send outgoing SMS messages.
+type MessageSendSchedule struct {
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
+ Name string `json:"name" example:"Business Hours"`
+ Timezone string `json:"timezone" example:"Europe/Tallinn"`
+ Windows []MessageSendScheduleWindow `json:"windows" gorm:"type:jsonb;serializer:json"`
+ CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
+ UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
+}
+
+// ResolveScheduledAt returns the next allowed send time based on the schedule.
+// If the schedule is inactive, has no windows, or has an invalid timezone,
+// the current time is returned in UTC. An active schedule with no windows
+// is treated as inactive (messages are sent immediately).
+func (schedule *MessageSendSchedule) ResolveScheduledAt(current time.Time) time.Time {
+ if schedule == nil || len(schedule.Windows) == 0 {
+ return current.UTC()
+ }
+
+ location, err := time.LoadLocation(schedule.Timezone)
+ if err != nil {
+ return current.UTC()
+ }
+
+ base := current.In(location)
+ var best time.Time
+
+ for dayOffset := 0; dayOffset <= 7; dayOffset++ {
+ day := base.AddDate(0, 0, dayOffset)
+ weekday := int(day.Weekday())
+
+ for _, window := range schedule.Windows {
+ if window.DayOfWeek != weekday {
+ continue
+ }
+
+ start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).
+ Add(time.Duration(window.StartMinute) * time.Minute)
+
+ end := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location).
+ Add(time.Duration(window.EndMinute) * time.Minute)
+
+ var candidate time.Time
+
+ switch {
+ case dayOffset == 0 && base.Before(start):
+ candidate = start
+ case dayOffset == 0 && (base.Equal(start) || (base.After(start) && base.Before(end))):
+ candidate = base
+ case dayOffset > 0:
+ candidate = start
+ default:
+ continue
+ }
+
+ if best.IsZero() || candidate.Before(best) {
+ best = candidate
+ }
+ }
+
+ if !best.IsZero() {
+ break
+ }
+ }
+
+ if best.IsZero() {
+ return current.UTC()
+ }
+
+ return best.UTC()
+}
diff --git a/api/pkg/entities/message_send_schedule_test.go b/api/pkg/entities/message_send_schedule_test.go
new file mode 100644
index 00000000..2554fda3
--- /dev/null
+++ b/api/pkg/entities/message_send_schedule_test.go
@@ -0,0 +1,59 @@
+package entities
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResolveScheduledAt_NilSchedule_ReturnsCurrentUTC(t *testing.T) {
+ now := time.Now()
+ var schedule *MessageSendSchedule
+ result := schedule.ResolveScheduledAt(now)
+ assert.Equal(t, now.UTC(), result)
+}
+
+func TestResolveScheduledAt_InactiveSchedule_ReturnsCurrentUTC(t *testing.T) {
+ now := time.Now()
+ schedule := &MessageSendSchedule{}
+ result := schedule.ResolveScheduledAt(now)
+ assert.Equal(t, now.UTC(), result)
+}
+
+func TestResolveScheduledAt_NoWindows_ReturnsCurrentUTC(t *testing.T) {
+ now := time.Now()
+ schedule := &MessageSendSchedule{
+ Timezone: "UTC",
+ Windows: []MessageSendScheduleWindow{},
+ }
+ result := schedule.ResolveScheduledAt(now)
+ assert.Equal(t, now.UTC(), result)
+}
+
+func TestResolveScheduledAt_WithinWindow_ReturnsCurrentUTC(t *testing.T) {
+ // Wednesday at 10:00 UTC, window is Wed 9:00-17:00 (540-1020 minutes)
+ now := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) // Wednesday
+ schedule := &MessageSendSchedule{
+ Timezone: "UTC",
+ Windows: []MessageSendScheduleWindow{
+ {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020},
+ },
+ }
+ result := schedule.ResolveScheduledAt(now)
+ assert.Equal(t, now.UTC(), result)
+}
+
+func TestResolveScheduledAt_BeforeWindow_ReturnsWindowStart(t *testing.T) {
+ // Wednesday at 7:00 UTC, window is Wed 9:00-17:00
+ now := time.Date(2025, 1, 1, 7, 0, 0, 0, time.UTC) // Wednesday
+ schedule := &MessageSendSchedule{
+ Timezone: "UTC",
+ Windows: []MessageSendScheduleWindow{
+ {DayOfWeek: int(now.Weekday()), StartMinute: 540, EndMinute: 1020},
+ },
+ }
+ result := schedule.ResolveScheduledAt(now)
+ expected := time.Date(2025, 1, 1, 9, 0, 0, 0, time.UTC)
+ assert.Equal(t, expected, result)
+}
diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go
index 83521759..97df6631 100644
--- a/api/pkg/entities/phone.go
+++ b/api/pkg/entities/phone.go
@@ -8,12 +8,14 @@ import (
// Phone represents an android phone which has installed the http sms app
type Phone struct {
- ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
- UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
- FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"`
- PhoneNumber string `json:"phone_number" example:"+18005550199"`
- MessagesPerMinute uint `json:"messages_per_minute" example:"1"`
- SIM SIM `json:"sim" gorm:"default:SIM1"`
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
+ UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
+ FcmToken *string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." validate:"optional"`
+ PhoneNumber string `json:"phone_number" example:"+18005550199"`
+ MessagesPerMinute uint `json:"messages_per_minute" example:"1"`
+ SIM SIM `json:"sim" gorm:"default:SIM1"`
+ MessageSendScheduleID *uuid.UUID `json:"message_send_schedule_id" gorm:"type:uuid" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"`
+
// MaxSendAttempts determines how many times to retry sending an SMS message
MaxSendAttempts uint `json:"max_send_attempts" example:"2"`
diff --git a/api/pkg/entities/phone_api_key.go b/api/pkg/entities/phone_api_key.go
index 5a32c234..d8dda328 100644
--- a/api/pkg/entities/phone_api_key.go
+++ b/api/pkg/entities/phone_api_key.go
@@ -7,6 +7,9 @@ import (
"github.com/lib/pq"
)
+// EntityNamePhoneAPIKey is the entitlement entity name for phone API keys.
+const EntityNamePhoneAPIKey = "PhoneAPIKey"
+
// PhoneAPIKey represents the API key for a phone
type PhoneAPIKey struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
diff --git a/api/pkg/entities/user.go b/api/pkg/entities/user.go
index a6a92371..6cf2eba9 100644
--- a/api/pkg/entities/user.go
+++ b/api/pkg/entities/user.go
@@ -78,7 +78,7 @@ type User struct {
Timezone string `json:"timezone" example:"Europe/Helsinki" gorm:"default:Africa/Accra"`
ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"`
SubscriptionName SubscriptionName `json:"subscription_name" example:"free"`
- SubscriptionID *string `json:"-" example:"8f9c71b8-b84e-4417-8408-a62274f65a08" swaggerignore:"true"`
+ SubscriptionID *string `json:"subscription_id" example:"8f9c71b8-b84e-4417-8408-a62274f65a08"`
SubscriptionStatus *string `json:"subscription_status" example:"on_trial" validate:"optional"`
SubscriptionRenewsAt *time.Time `json:"subscription_renews_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"`
SubscriptionEndsAt *time.Time `json:"subscription_ends_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"`
@@ -127,3 +127,13 @@ func (user User) Location() *time.Location {
}
return location
}
+
+// GetBillingAnchorDay returns the day-of-month that anchors this user's billing cycle.
+// For paid users with an active subscription, it uses the renewal date.
+// For free users or when SubscriptionRenewsAt is nil, it falls back to the account creation date.
+func (user User) GetBillingAnchorDay() int {
+ if user.SubscriptionRenewsAt != nil && !user.IsOnFreePlan() {
+ return user.SubscriptionRenewsAt.Day()
+ }
+ return user.CreatedAt.Day()
+}
diff --git a/api/pkg/entities/user_test.go b/api/pkg/entities/user_test.go
new file mode 100644
index 00000000..0417e63f
--- /dev/null
+++ b/api/pkg/entities/user_test.go
@@ -0,0 +1,53 @@
+package entities
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUser_GetBillingAnchorDay_FreeUser(t *testing.T) {
+ user := User{
+ SubscriptionName: SubscriptionNameFree,
+ CreatedAt: time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC),
+ }
+ assert.Equal(t, 20, user.GetBillingAnchorDay())
+}
+
+func TestUser_GetBillingAnchorDay_EmptySubscription(t *testing.T) {
+ user := User{
+ SubscriptionName: "",
+ CreatedAt: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
+ }
+ assert.Equal(t, 5, user.GetBillingAnchorDay())
+}
+
+func TestUser_GetBillingAnchorDay_PaidUser(t *testing.T) {
+ renewsAt := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
+ user := User{
+ SubscriptionName: SubscriptionNameProMonthly,
+ SubscriptionRenewsAt: &renewsAt,
+ CreatedAt: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
+ }
+ assert.Equal(t, 15, user.GetBillingAnchorDay())
+}
+
+func TestUser_GetBillingAnchorDay_PaidUserNilRenewsAt(t *testing.T) {
+ user := User{
+ SubscriptionName: SubscriptionNameProMonthly,
+ SubscriptionRenewsAt: nil,
+ CreatedAt: time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC),
+ }
+ assert.Equal(t, 28, user.GetBillingAnchorDay())
+}
+
+func TestUser_GetBillingAnchorDay_PaidUserDay31(t *testing.T) {
+ renewsAt := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
+ user := User{
+ SubscriptionName: SubscriptionNameUltraMonthly,
+ SubscriptionRenewsAt: &renewsAt,
+ CreatedAt: time.Date(2025, 12, 1, 10, 0, 0, 0, time.UTC),
+ }
+ assert.Equal(t, 31, user.GetBillingAnchorDay())
+}
diff --git a/api/pkg/events/message_api_sent_event.go b/api/pkg/events/message_api_sent_event.go
index 7abea843..e86c911e 100644
--- a/api/pkg/events/message_api_sent_event.go
+++ b/api/pkg/events/message_api_sent_event.go
@@ -20,8 +20,10 @@ type MessageAPISentPayload struct {
MaxSendAttempts uint `json:"max_send_attempts"`
Contact string `json:"contact"`
ScheduledSendTime *time.Time `json:"scheduled_send_time"`
+ ExactSendTime bool `json:"exact_send_time"`
RequestReceivedAt time.Time `json:"request_received_at"`
Content string `json:"content"`
+ Attachments []string `json:"attachments"`
Encrypted bool `json:"encrypted"`
SIM entities.SIM `json:"sim"`
}
diff --git a/api/pkg/events/message_phone_received_event.go b/api/pkg/events/message_phone_received_event.go
index abe3a014..04dd6c2e 100644
--- a/api/pkg/events/message_phone_received_event.go
+++ b/api/pkg/events/message_phone_received_event.go
@@ -13,12 +13,13 @@ const EventTypeMessagePhoneReceived = "message.phone.received"
// MessagePhoneReceivedPayload is the payload of the EventTypeMessagePhoneReceived event
type MessagePhoneReceivedPayload struct {
- MessageID uuid.UUID `json:"message_id"`
- UserID entities.UserID `json:"user_id"`
- Owner string `json:"owner"`
- Encrypted bool `json:"encrypted"`
- Contact string `json:"contact"`
- Timestamp time.Time `json:"timestamp"`
- Content string `json:"content"`
- SIM entities.SIM `json:"sim"`
+ MessageID uuid.UUID `json:"message_id"`
+ UserID entities.UserID `json:"user_id"`
+ Owner string `json:"owner"`
+ Encrypted bool `json:"encrypted"`
+ Contact string `json:"contact"`
+ Timestamp time.Time `json:"timestamp"`
+ Content string `json:"content"`
+ SIM entities.SIM `json:"sim"`
+ Attachments []string `json:"attachments"`
}
diff --git a/api/pkg/events/message_send_schedule_deleted_event.go b/api/pkg/events/message_send_schedule_deleted_event.go
new file mode 100644
index 00000000..3a32361c
--- /dev/null
+++ b/api/pkg/events/message_send_schedule_deleted_event.go
@@ -0,0 +1,18 @@
+package events
+
+import (
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/google/uuid"
+)
+
+// EventTypeMessageSendScheduleDeleted is emitted when a message send schedule is deleted
+const EventTypeMessageSendScheduleDeleted = "message-send-schedule.deleted"
+
+// MessageSendScheduleDeletedPayload is the payload of the EventTypeMessageSendScheduleDeleted event
+type MessageSendScheduleDeletedPayload struct {
+ ScheduleID uuid.UUID `json:"schedule_id"`
+ UserID entities.UserID `json:"user_id"`
+ Timestamp time.Time `json:"timestamp"`
+}
diff --git a/api/pkg/handlers/attachment_handler.go b/api/pkg/handlers/attachment_handler.go
new file mode 100644
index 00000000..46a4397b
--- /dev/null
+++ b/api/pkg/handlers/attachment_handler.go
@@ -0,0 +1,85 @@
+package handlers
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/gofiber/fiber/v2"
+ "github.com/palantir/stacktrace"
+)
+
+// AttachmentHandler handles attachment download requests
+type AttachmentHandler struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ storage repositories.AttachmentRepository
+}
+
+// NewAttachmentHandler creates a new AttachmentHandler
+func NewAttachmentHandler(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ storage repositories.AttachmentRepository,
+) (h *AttachmentHandler) {
+ return &AttachmentHandler{
+ logger: logger.WithService(fmt.Sprintf("%T", h)),
+ tracer: tracer,
+ storage: storage,
+ }
+}
+
+// RegisterRoutes registers the routes for the AttachmentHandler (no auth middleware — public endpoint)
+func (h *AttachmentHandler) RegisterRoutes(router fiber.Router) {
+ router.Get("/v1/attachments/:userID/:messageID/:attachmentIndex/:filename", h.GetAttachment)
+}
+
+// GetAttachment Downloads an attachment
+// @Summary Download a message attachment
+// @Description Download an MMS attachment by its path components
+// @Tags Attachments
+// @Produce application/octet-stream
+// @Param userID path string true "User ID"
+// @Param messageID path string true "Message ID"
+// @Param attachmentIndex path string true "Attachment index"
+// @Param filename path string true "Filename with extension"
+// @Success 200 {file} binary
+// @Failure 404 {object} responses.NotFound
+// @Failure 500 {object} responses.InternalServerError
+// @Router /v1/attachments/{userID}/{messageID}/{attachmentIndex}/{filename} [get]
+func (h *AttachmentHandler) GetAttachment(c *fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ userID := c.Params("userID")
+ messageID := c.Params("messageID")
+ attachmentIndex := c.Params("attachmentIndex")
+ filename := c.Params("filename")
+
+ path := fmt.Sprintf("attachments/%s/%s/%s/%s", userID, messageID, attachmentIndex, filename)
+
+ ctxLogger.Info(fmt.Sprintf("downloading attachment from path [%s]", path))
+
+ data, err := h.storage.Download(ctx, path)
+ if err != nil {
+ msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
+ ctxLogger.Warn(stacktrace.Propagate(err, msg))
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, "attachment not found")
+ }
+ return h.responseInternalServerError(c)
+ }
+
+ ext := filepath.Ext(filename)
+ contentType := repositories.ContentTypeFromExtension(ext)
+
+ c.Set("Content-Type", contentType)
+ c.Set("Content-Disposition", "attachment")
+ c.Set("X-Content-Type-Options", "nosniff")
+
+ return c.Send(data)
+}
diff --git a/api/pkg/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go
index bcdb5248..3d65ee9a 100644
--- a/api/pkg/handlers/billing_handler.go
+++ b/api/pkg/handlers/billing_handler.go
@@ -65,7 +65,7 @@ func (h *BillingHandler) UsageHistory(c *fiber.Ctx) error {
var request requests.BillingUsageHistory
if err := c.QueryParser(&request); err != nil {
- msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)
+ msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request)
ctxLogger.Warn(stacktrace.Propagate(err, msg))
return h.responseBadRequest(c, err)
}
diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go
index c660eeaa..ce1f0354 100644
--- a/api/pkg/handlers/bulk_message_handler.go
+++ b/api/pkg/handlers/bulk_message_handler.go
@@ -2,12 +2,13 @@ package handlers
import (
"fmt"
+ "path/filepath"
+ "regexp"
"sync"
"sync/atomic"
+ "time"
"github.com/NdoleStudio/httpsms/pkg/requests"
- "github.com/google/uuid"
-
"github.com/NdoleStudio/httpsms/pkg/services"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/NdoleStudio/httpsms/pkg/validators"
@@ -45,9 +46,35 @@ func NewBulkMessageHandler(
// RegisterRoutes registers the routes for the MessageHandler
func (h *BulkMessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) {
+ router.Get("/v1/bulk-messages", h.computeRoute(middlewares, h.Index)...)
router.Post("/v1/bulk-messages", h.computeRoute(middlewares, h.Store)...)
}
+// Index fetches the bulk message order history.
+// @Summary List bulk message orders
+// @Description Fetches the last 10 bulk message order summaries for the authenticated user showing counts per status.
+// @Security ApiKeyAuth
+// @Tags BulkSMS
+// @Accept json
+// @Produce json
+// @Success 200 {object} responses.BulkMessagesResponse
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 500 {object} responses.InternalServerError
+// @Router /bulk-messages [get]
+func (h *BulkMessageHandler) Index(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ orders, err := h.messageService.GetBulkMessages(ctx, h.userIDFomContext(c))
+ if err != nil {
+ msg := fmt.Sprintf("cannot fetch bulk messages for user [%s]", h.userIDFomContext(c))
+ ctxLogger.Error(stacktrace.Propagate(err, msg))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseOK(c, fmt.Sprintf("fetched %d bulk %s", len(orders), h.pluralize("message", len(orders))), orders)
+}
+
// Store sends bulk SMS messages from a CSV or Excel file.
// @Summary Store bulk SMS file
// @Description Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).
@@ -73,7 +100,7 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error {
return h.responseBadRequest(c, err)
}
- messages, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file)
+ messages, userLocation, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file)
if len(validationErrors) != 0 {
msg := fmt.Sprintf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c))
ctxLogger.Warn(stacktrace.NewError(msg))
@@ -85,27 +112,75 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error {
return h.responsePaymentRequired(c, *msg)
}
- requestID := uuid.New()
+ requestID := h.generateRequestID(file.Filename)
wg := sync.WaitGroup{}
count := atomic.Int64{}
- for index, message := range messages {
+ // Compute per-phone index for rate-based dispatch delay
+ phoneIndexCounter := make(map[string]int)
+
+ for _, message := range messages {
wg.Add(1)
+ var perPhoneIndex int
+ if message.GetSendTime(userLocation) == nil {
+ perPhoneIndex = phoneIndexCounter[message.FromPhoneNumber]
+ phoneIndexCounter[message.FromPhoneNumber]++
+ }
+
go func(message *requests.BulkMessage, index int) {
count.Add(1)
_, err = h.messageService.SendMessage(
ctx,
- message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL()),
+ message.ToMessageSendParams(h.userIDFomContext(c), requestID, c.OriginalURL(), index, userLocation),
)
if err != nil {
count.Add(-1)
- msg := fmt.Sprintf("cannot send message with paylod [%s] at index [%d]", spew.Sdump(message), index)
+ msg := fmt.Sprintf("cannot send message with payload [%s] at index [%d]", spew.Sdump(message), index)
ctxLogger.Error(stacktrace.Propagate(err, msg))
}
wg.Done()
- }(message, index)
+ }(message, perPhoneIndex)
}
wg.Wait()
return h.responseAccepted(c, fmt.Sprintf("Added %d out of %d messages to the queue", count.Load(), len(messages)))
}
+
+func (h *BulkMessageHandler) generateRequestID(filename string) string {
+ return fmt.Sprintf("bulk-%s-%s", encodeBase62(time.Now().UnixMilli()), truncateFilename(sanitizeFilename(filename), 32))
+}
+
+func sanitizeFilename(filename string) string {
+ return regexp.MustCompile(`[^a-zA-Z0-9.\-_: ]`).ReplaceAllString(filename, "")
+}
+
+func encodeBase62(n int64) string {
+ const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ if n == 0 {
+ return "0"
+ }
+ result := make([]byte, 0, 8)
+ for n > 0 {
+ result = append(result, charset[n%62])
+ n /= 62
+ }
+ // reverse
+ for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
+ result[i], result[j] = result[j], result[i]
+ }
+ return string(result)
+}
+
+func truncateFilename(filename string, maxLen int) string {
+ if len(filename) <= maxLen {
+ return filename
+ }
+ ext := filepath.Ext(filename)
+ name := filename[:len(filename)-len(ext)]
+ available := maxLen - len(ext)
+ if available <= 0 {
+ return filename[:maxLen]
+ }
+ half := available / 2
+ return name[:half] + name[len(name)-(available-half):] + ext
+}
diff --git a/api/pkg/handlers/events_handler.go b/api/pkg/handlers/events_handler.go
index 16d0325d..821f99f6 100644
--- a/api/pkg/handlers/events_handler.go
+++ b/api/pkg/handlers/events_handler.go
@@ -44,11 +44,9 @@ func (h *EventsHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber
// Dispatch a cloud event
// This is an internal API so no documentation provided
func (h *EventsHandler) Dispatch(c *fiber.Ctx) error {
- ctx, span := h.tracer.StartFromFiberCtx(c)
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
defer span.End()
- ctxLogger := h.tracer.CtxLogger(h.logger, span)
-
var request cloudevents.Event
if err := c.BodyParser(&request); err != nil {
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)
diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go
index f0ec9de2..9504d518 100644
--- a/api/pkg/handlers/message_handler.go
+++ b/api/pkg/handlers/message_handler.go
@@ -54,6 +54,7 @@ func (h *MessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fibe
router.Post("/v1/messages/bulk-send", h.computeRoute(middlewares, h.BulkSend)...)
router.Get("/v1/messages", h.computeRoute(middlewares, h.Index)...)
router.Get("/v1/messages/search", h.computeRoute(middlewares, h.Search)...)
+ router.Get("/v1/messages/:messageID", h.computeRoute(middlewares, h.Get)...)
router.Delete("/v1/messages/:messageID", h.computeRoute(middlewares, h.Delete)...)
}
@@ -160,11 +161,6 @@ func (h *MessageHandler) BulkSend(c *fiber.Ctx) error {
wg.Add(1)
go func(message services.MessageSendParams, index int) {
count.Add(1)
- if message.SendAt == nil {
- sentAt := time.Now().UTC().Add(time.Duration(index) * time.Second)
- message.SendAt = &sentAt
- }
-
response, err := h.service.SendMessage(ctx, message)
if err != nil {
count.Add(-1)
@@ -443,6 +439,48 @@ func (h *MessageHandler) Delete(c *fiber.Ctx) error {
return h.responseNoContent(c, "message deleted successfully")
}
+// Get a message
+// @Summary Get a message from the database.
+// @Description Get a message from the database by the message ID.
+// @Security ApiKeyAuth
+// @Tags Messages
+// @Accept json
+// @Produce json
+// @Param messageID path string true "ID of the message" default(32343a19-da5e-4b1b-a767-3298a73703ca)
+// @Success 204 {object} responses.MessageResponse
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 404 {object} responses.NotFound
+// @Failure 422 {object} responses.UnprocessableEntity
+// @Failure 500 {object} responses.InternalServerError
+// @Router /messages/{messageID} [get]
+func (h *MessageHandler) Get(c *fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ messageID := c.Params("messageID")
+ if errors := h.validator.ValidateUUID(messageID, "messageID"); len(errors) != 0 {
+ msg := fmt.Sprintf("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID)
+ ctxLogger.Warn(stacktrace.NewError(msg))
+ return h.responseUnprocessableEntity(c, errors, "validation errors while storing event")
+ }
+
+ message, err := h.service.GetMessage(ctx, h.userIDFomContext(c), uuid.MustParse(messageID))
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, fmt.Sprintf("cannot find message with ID [%s]", messageID))
+ }
+
+ if err != nil {
+ msg := fmt.Sprintf("cannot find message with id [%s]", messageID)
+ ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseOK(c, "message fetched successfully", message)
+}
+
// PostCallMissed registers a missed phone call
// @Summary Register a missed call event on the mobile phone
// @Description This endpoint is called by the httpSMS android app to register a missed call event on the mobile phone.
diff --git a/api/pkg/handlers/message_send_schedule_handler.go b/api/pkg/handlers/message_send_schedule_handler.go
new file mode 100644
index 00000000..3218dceb
--- /dev/null
+++ b/api/pkg/handlers/message_send_schedule_handler.go
@@ -0,0 +1,225 @@
+package handlers
+
+import (
+ "fmt"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/requests"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/NdoleStudio/httpsms/pkg/validators"
+ "github.com/davecgh/go-spew/spew"
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+ "github.com/palantir/stacktrace"
+)
+
+// MessageSendScheduleHandler handles HTTP requests for message send schedules.
+type MessageSendScheduleHandler struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ validator *validators.MessageSendScheduleHandlerValidator
+ service *services.MessageSendScheduleService
+ entitlementService *services.EntitlementService
+}
+
+// NewMessageSendScheduleHandler creates a new MessageSendScheduleHandler.
+func NewMessageSendScheduleHandler(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ validator *validators.MessageSendScheduleHandlerValidator,
+ service *services.MessageSendScheduleService,
+ entitlementService *services.EntitlementService,
+) *MessageSendScheduleHandler {
+ return &MessageSendScheduleHandler{
+ logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandler{})),
+ tracer: tracer,
+ validator: validator,
+ service: service,
+ entitlementService: entitlementService,
+ }
+}
+
+// RegisterRoutes registers send schedule routes.
+func (h *MessageSendScheduleHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) {
+ router.Get("/v1/send-schedules", h.computeRoute(middlewares, h.Index)...)
+ router.Post("/v1/send-schedules", h.computeRoute(middlewares, h.Store)...)
+ router.Put("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Update)...)
+ router.Delete("/v1/send-schedules/:scheduleID", h.computeRoute(middlewares, h.Delete)...)
+}
+
+// Index lists all send schedules for the authenticated user.
+//
+// @Summary List send schedules
+// @Description List all send schedules owned by the authenticated user.
+// @Security ApiKeyAuth
+// @Tags SendSchedules
+// @Produce json
+// @Success 200 {object} responses.MessageSendSchedulesResponse
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 500 {object} responses.InternalServerError
+// @Router /send-schedules [get]
+func (h *MessageSendScheduleHandler) Index(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ userID := h.userIDFomContext(c)
+
+ schedules, err := h.service.Index(ctx, userID)
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot list send schedules for user [%s]", userID)))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseOK(c, "send schedules fetched successfully", schedules)
+}
+
+// Store creates a new send schedule for the authenticated user.
+//
+// @Summary Create send schedule
+// @Description Create a new send schedule for the authenticated user.
+// @Security ApiKeyAuth
+// @Tags SendSchedules
+// @Accept json
+// @Produce json
+// @Param payload body requests.MessageSendScheduleStore true "Payload of new send schedule."
+// @Success 201 {object} responses.MessageSendScheduleResponse
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 402 {object} responses.PaymentRequired
+// @Failure 422 {object} responses.UnprocessableEntity
+// @Failure 500 {object} responses.InternalServerError
+// @Router /send-schedules [post]
+func (h *MessageSendScheduleHandler) Store(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ userID := h.userIDFomContext(c)
+
+ result, err := h.entitlementService.Check(ctx, userID, entities.EntityNameMessageSendSchedule, func() (int, error) {
+ return h.service.CountByUser(ctx, userID)
+ })
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot check entitlement for send schedules for user [%s]", userID)))
+ return h.responseInternalServerError(c)
+ }
+ if !result.Allowed {
+ return h.responsePaymentRequired(c, result.Message)
+ }
+
+ var request requests.MessageSendScheduleStore
+ if err = c.BodyParser(&request); err != nil {
+ return h.responseBadRequest(c, err)
+ }
+
+ request = request.Sanitize()
+ if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 {
+ ctxLogger.Warn(stacktrace.NewError(
+ "validation errors [%s], while storing send schedule [%+#v]",
+ spew.Sdump(errors),
+ request,
+ ))
+ return h.responseUnprocessableEntity(c, errors, "validation errors while saving send schedule")
+ }
+
+ schedule, err := h.service.Store(ctx, request.ToParams(h.userFromContext(c)))
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot create send schedule for user [%s]", userID)))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseCreated(c, "send schedule created successfully", schedule)
+}
+
+// Update updates a send schedule owned by the authenticated user.
+//
+// @Summary Update send schedule
+// @Description Update a send schedule owned by the authenticated user.
+// @Security ApiKeyAuth
+// @Tags SendSchedules
+// @Accept json
+// @Produce json
+// @Param scheduleID path string true "Schedule ID"
+// @Param payload body requests.MessageSendScheduleStore true "Payload of updated send schedule."
+// @Success 200 {object} responses.MessageSendScheduleResponse
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 404 {object} responses.NotFound
+// @Failure 422 {object} responses.UnprocessableEntity
+// @Failure 500 {object} responses.InternalServerError
+// @Router /send-schedules/{scheduleID} [put]
+func (h *MessageSendScheduleHandler) Update(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ scheduleID, err := uuid.Parse(c.Params("scheduleID"))
+ if err != nil {
+ return h.responseBadRequest(c, err)
+ }
+
+ var request requests.MessageSendScheduleStore
+ if err = c.BodyParser(&request); err != nil {
+ return h.responseBadRequest(c, err)
+ }
+
+ request = request.Sanitize()
+ if errors := h.validator.ValidateStore(ctx, request); len(errors) != 0 {
+ return h.responseUnprocessableEntity(c, errors, "validation errors while updating send schedule")
+ }
+
+ userID := h.userIDFomContext(c)
+
+ schedule, err := h.service.Update(ctx, userID, scheduleID, request.ToParams(h.userFromContext(c)))
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot update send schedule for user [%s] and schedule [%s]", userID, scheduleID)))
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, err.Error())
+ }
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseOK(c, "send schedule updated successfully", schedule)
+}
+
+// Delete removes a send schedule owned by the authenticated user.
+//
+// @Summary Delete send schedule
+// @Description Delete a send schedule owned by the authenticated user.
+// @Security ApiKeyAuth
+// @Tags SendSchedules
+// @Produce json
+// @Param scheduleID path string true "Schedule ID"
+// @Success 204
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 404 {object} responses.NotFound
+// @Failure 500 {object} responses.InternalServerError
+// @Router /send-schedules/{scheduleID} [delete]
+func (h *MessageSendScheduleHandler) Delete(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ scheduleID, err := uuid.Parse(c.Params("scheduleID"))
+ if err != nil {
+ return h.responseBadRequest(c, err)
+ }
+
+ userID := h.userIDFomContext(c)
+
+ if _, err = h.service.Load(ctx, userID, scheduleID); err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load send schedule for deletion for user [%s] and schedule [%s]", userID, scheduleID)))
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, err.Error())
+ }
+ return h.responseInternalServerError(c)
+ }
+
+ if err = h.service.Delete(ctx, userID, scheduleID); err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot delete send schedule for user [%s] and schedule [%s]", userID, scheduleID)))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseNoContent(c, "send schedule deleted successfully")
+}
diff --git a/api/pkg/handlers/phone_api_key_handler.go b/api/pkg/handlers/phone_api_key_handler.go
index c10df513..4cd7e1ab 100644
--- a/api/pkg/handlers/phone_api_key_handler.go
+++ b/api/pkg/handlers/phone_api_key_handler.go
@@ -3,6 +3,7 @@ package handlers
import (
"fmt"
+ "github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/requests"
"github.com/NdoleStudio/httpsms/pkg/services"
@@ -17,10 +18,11 @@ import (
// PhoneAPIKeyHandler handles phone API key http requests
type PhoneAPIKeyHandler struct {
handler
- logger telemetry.Logger
- tracer telemetry.Tracer
- validator *validators.PhoneAPIKeyHandlerValidator
- service *services.PhoneAPIKeyService
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ validator *validators.PhoneAPIKeyHandlerValidator
+ service *services.PhoneAPIKeyService
+ entitlementService *services.EntitlementService
}
// NewPhoneAPIKeyHandler creates a new PhoneAPIKeyHandler
@@ -29,12 +31,14 @@ func NewPhoneAPIKeyHandler(
tracer telemetry.Tracer,
validator *validators.PhoneAPIKeyHandlerValidator,
service *services.PhoneAPIKeyService,
+ entitlementService *services.EntitlementService,
) *PhoneAPIKeyHandler {
return &PhoneAPIKeyHandler{
- logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})),
- tracer: tracer,
- validator: validator,
- service: service,
+ logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})),
+ tracer: tracer,
+ validator: validator,
+ service: service,
+ entitlementService: entitlementService,
}
}
@@ -99,6 +103,7 @@ func (h *PhoneAPIKeyHandler) index(c *fiber.Ctx) error {
// @Success 200 {object} responses.PhoneAPIKeyResponse
// @Failure 400 {object} responses.BadRequest
// @Failure 401 {object} responses.Unauthorized
+// @Failure 402 {object} responses.PaymentRequired
// @Failure 422 {object} responses.UnprocessableEntity
// @Failure 500 {object} responses.InternalServerError
// @Router /phone-api-keys [post]
@@ -106,6 +111,19 @@ func (h *PhoneAPIKeyHandler) store(c *fiber.Ctx) error {
ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
defer span.End()
+ userID := h.userIDFomContext(c)
+
+ result, err := h.entitlementService.Check(ctx, userID, entities.EntityNamePhoneAPIKey, func() (int, error) {
+ return h.service.CountByUser(ctx, userID)
+ })
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot check entitlement for phone API keys for user [%s]", userID)))
+ return h.responseInternalServerError(c)
+ }
+ if !result.Allowed {
+ return h.responsePaymentRequired(c, result.Message)
+ }
+
var request requests.PhoneAPIKeyStoreRequest
if err := c.BodyParser(&request); err != nil {
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)
diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go
index 9e5cbe1c..4c8efa09 100644
--- a/api/pkg/handlers/phone_handler.go
+++ b/api/pkg/handlers/phone_handler.go
@@ -3,6 +3,7 @@ package handlers
import (
"fmt"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/requests"
"github.com/NdoleStudio/httpsms/pkg/validators"
"github.com/davecgh/go-spew/spew"
@@ -121,13 +122,13 @@ func (h *PhoneHandler) Upsert(c *fiber.Ctx) error {
return h.responseBadRequest(c, err)
}
- if errors := h.validator.ValidateUpsert(ctx, request.Sanitize()); len(errors) != 0 {
+ if errors := h.validator.ValidateUpsert(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 {
msg := fmt.Sprintf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request)
ctxLogger.Warn(stacktrace.NewError(msg))
return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones")
}
- phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL()))
+ phone, err := h.service.Upsert(ctx, request.ToUpsertParams(h.userFromContext(c), c.OriginalURL(), c.Body()))
if err != nil {
msg := fmt.Sprintf("cannot update phones with params [%+#v]", request)
ctxLogger.Error(stacktrace.Propagate(err, msg))
@@ -165,6 +166,9 @@ func (h *PhoneHandler) Delete(c *fiber.Ctx) error {
}
err := h.service.Delete(ctx, c.OriginalURL(), h.userIDFomContext(c), request.PhoneIDUuid())
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, fmt.Sprintf("cannot find phone with ID [%s]", request.PhoneID))
+ }
if err != nil {
msg := fmt.Sprintf("cannot delete phones with params [%+#v]", request)
ctxLogger.Error(stacktrace.Propagate(err, msg))
diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go
index 44ec619e..d63046dc 100644
--- a/api/pkg/handlers/user_handler.go
+++ b/api/pkg/handlers/user_handler.go
@@ -46,6 +46,8 @@ func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.H
router.Put("/v1/users/:userID/notifications", h.computeRoute(middlewares, h.UpdateNotifications)...)
router.Get("/v1/users/subscription-update-url", h.computeRoute(middlewares, h.subscriptionUpdateURL)...)
router.Delete("/v1/users/subscription", h.computeRoute(middlewares, h.cancelSubscription)...)
+ router.Get("/v1/users/subscription/payments", h.computeRoute(middlewares, h.subscriptionPayments)...)
+ router.Post("/v1/users/subscription/invoices/:subscriptionInvoiceID", h.computeRoute(middlewares, h.subscriptionInvoice)...)
}
// Show returns an entities.User
@@ -159,11 +161,9 @@ func (h *UserHandler) Delete(c *fiber.Ctx) error {
// @Failure 500 {object} responses.InternalServerError
// @Router /users/{userID}/notifications [put]
func (h *UserHandler) UpdateNotifications(c *fiber.Ctx) error {
- ctx, span := h.tracer.StartFromFiberCtx(c)
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
defer span.End()
- ctxLogger := h.tracer.CtxLogger(h.logger, span)
-
var request requests.UserNotificationUpdate
if err := c.BodyParser(&request); err != nil {
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request)
@@ -272,3 +272,76 @@ func (h *UserHandler) DeleteAPIKey(c *fiber.Ctx) error {
return h.responseOK(c, "API Key rotated successfully", user)
}
+
+// subscriptionPayments returns the last 10 payments of the currently authenticated user
+// @Summary Get the last 10 subscription payments.
+// @Description Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.
+// @Security ApiKeyAuth
+// @Tags Users
+// @Accept json
+// @Produce json
+// @Success 200 {object} responses.UserSubscriptionPaymentsResponse
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 422 {object} responses.UnprocessableEntity
+// @Failure 500 {object} responses.InternalServerError
+// @Router /users/subscription/payments [get]
+func (h *UserHandler) subscriptionPayments(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ invoices, err := h.service.GetSubscriptionPayments(ctx, h.userIDFomContext(c))
+ if err != nil {
+ msg := fmt.Sprintf("cannot get current subscription invoices for user [%s]", h.userFromContext(c))
+ ctxLogger.Error(stacktrace.Propagate(err, msg))
+ return h.responseInternalServerError(c)
+ }
+
+ return h.responseOK(c, "fetched subscription invoices billing usage", invoices)
+}
+
+// subscriptionInvoice generates an invoice for a given subscription invoice ID
+// @Summary Generate a subscription payment invoice
+// @Description Generates a new invoice PDF file for the given subscription payment with given parameters.
+// @Security ApiKeyAuth
+// @Tags Users
+// @Accept json
+// @Produce application/pdf
+// @Param payload body requests.UserPaymentInvoice true "Generate subscription payment invoice parameters"
+// @Param subscriptionInvoiceID path string true "ID of the subscription invoice to generate the PDF for"
+// @Success 200 {file} file
+// @Failure 400 {object} responses.BadRequest
+// @Failure 401 {object} responses.Unauthorized
+// @Failure 422 {object} responses.UnprocessableEntity
+// @Failure 500 {object} responses.InternalServerError
+// @Router /users/subscription/invoices/{subscriptionInvoiceID} [post]
+func (h *UserHandler) subscriptionInvoice(c *fiber.Ctx) error {
+ ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger)
+ defer span.End()
+
+ var request requests.UserPaymentInvoice
+ if err := c.BodyParser(&request); err != nil {
+ msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request)
+ ctxLogger.Warn(stacktrace.Propagate(err, msg))
+ return h.responseBadRequest(c, err)
+ }
+
+ request.SubscriptionInvoiceID = c.Params("subscriptionInvoiceID")
+ if errors := h.validator.ValidatePaymentInvoice(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 {
+ msg := fmt.Sprintf("validation errors [%s], while validating subscription payment invoice request [%s]", spew.Sdump(errors), c.Body())
+ ctxLogger.Warn(stacktrace.NewError(msg))
+ return h.responseUnprocessableEntity(c, errors, "validation errors while generating payment invoice")
+ }
+
+ data, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c)))
+ if err != nil {
+ msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c))
+ ctxLogger.Error(stacktrace.Propagate(err, msg))
+ return h.responseInternalServerError(c)
+ }
+
+ c.Set(fiber.HeaderContentType, "application/pdf")
+ c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"httpsms.com - %s.pdf\"", request.SubscriptionInvoiceID))
+
+ return c.SendStream(data)
+}
diff --git a/api/pkg/listeners/message_send_schedule_listener.go b/api/pkg/listeners/message_send_schedule_listener.go
new file mode 100644
index 00000000..20d4955d
--- /dev/null
+++ b/api/pkg/listeners/message_send_schedule_listener.go
@@ -0,0 +1,57 @@
+package listeners
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/NdoleStudio/httpsms/pkg/events"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ cloudevents "github.com/cloudevents/sdk-go/v2"
+ "github.com/palantir/stacktrace"
+)
+
+// MessageSendScheduleListener handles cloud events related to message send schedules.
+type MessageSendScheduleListener struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ service *services.MessageSendScheduleService
+}
+
+// NewMessageSendScheduleListener creates a new instance of MessageSendScheduleListener.
+func NewMessageSendScheduleListener(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ service *services.MessageSendScheduleService,
+) (l *MessageSendScheduleListener, routes map[string]events.EventListener) {
+ l = &MessageSendScheduleListener{
+ logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleListener{})),
+ tracer: tracer,
+ service: service,
+ }
+
+ return l, map[string]events.EventListener{
+ events.UserAccountDeleted: l.onUserAccountDeleted,
+ }
+}
+
+// onUserAccountDeleted removes all message send schedules for a deleted user account.
+func (listener *MessageSendScheduleListener) onUserAccountDeleted(
+ ctx context.Context,
+ event cloudevents.Event,
+) error {
+ ctx, span := listener.tracer.Start(ctx)
+ defer span.End()
+
+ var payload events.UserAccountDeletedPayload
+ if err := event.DataAs(&payload); err != nil {
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)))
+ }
+
+ if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil {
+ msg := fmt.Sprintf("cannot delete [entities.MessageSendSchedule] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
diff --git a/api/pkg/listeners/phone_listener.go b/api/pkg/listeners/phone_listener.go
new file mode 100644
index 00000000..541936d9
--- /dev/null
+++ b/api/pkg/listeners/phone_listener.go
@@ -0,0 +1,76 @@
+package listeners
+
+import (
+ "context"
+ "fmt"
+
+ cloudevents "github.com/cloudevents/sdk-go/v2"
+ "github.com/palantir/stacktrace"
+
+ "github.com/NdoleStudio/httpsms/pkg/events"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+)
+
+// PhoneListener handles cloud events that alter the state of entities.Phone
+type PhoneListener struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ service *services.PhoneService
+}
+
+// NewPhoneListener creates a new instance of PhoneListener
+func NewPhoneListener(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ service *services.PhoneService,
+) (l *PhoneListener, routes map[string]events.EventListener) {
+ l = &PhoneListener{
+ logger: logger.WithService(fmt.Sprintf("%T", l)),
+ tracer: tracer,
+ service: service,
+ }
+
+ return l, map[string]events.EventListener{
+ events.EventTypeMessageSendScheduleDeleted: l.onMessageSendScheduleDeleted,
+ events.UserAccountDeleted: l.onUserAccountDeleted,
+ }
+}
+
+// onMessageSendScheduleDeleted handles the events.EventTypeMessageSendScheduleDeleted event
+func (listener *PhoneListener) onMessageSendScheduleDeleted(ctx context.Context, event cloudevents.Event) error {
+ ctx, span := listener.tracer.Start(ctx)
+ defer span.End()
+
+ var payload events.MessageSendScheduleDeletedPayload
+ if err := event.DataAs(&payload); err != nil {
+ msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err := listener.service.NullifyScheduleID(ctx, payload.UserID, payload.ScheduleID); err != nil {
+ msg := fmt.Sprintf("cannot nullify schedule ID [%s] for user [%s] on [%s] event with ID [%s]", payload.ScheduleID, payload.UserID, event.Type(), event.ID())
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+// onUserAccountDeleted handles the events.UserAccountDeleted event
+func (listener *PhoneListener) onUserAccountDeleted(ctx context.Context, event cloudevents.Event) error {
+ ctx, span := listener.tracer.Start(ctx)
+ defer span.End()
+
+ var payload events.UserAccountDeletedPayload
+ if err := event.DataAs(&payload); err != nil {
+ msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil {
+ msg := fmt.Sprintf("cannot delete all [entities.Phone] for user [%s] on [%s] event with ID [%s]", payload.UserID, event.Type(), event.ID())
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
diff --git a/api/pkg/listeners/phone_notification_listener.go b/api/pkg/listeners/phone_notification_listener.go
index e1b3eef7..95333ef7 100644
--- a/api/pkg/listeners/phone_notification_listener.go
+++ b/api/pkg/listeners/phone_notification_listener.go
@@ -38,6 +38,7 @@ func NewNotificationListener(
events.EventTypeMessageNotificationSend: l.onMessageNotificationSend,
events.PhoneHeartbeatMissed: l.onPhoneHeartbeatMissed,
events.UserAccountDeleted: l.onUserAccountDeleted,
+ events.MessageAPIDeleted: l.onMessageAPIDeleted,
}
}
@@ -53,14 +54,16 @@ func (listener *PhoneNotificationListener) onMessageAPISent(ctx context.Context,
}
sendParams := &services.PhoneNotificationScheduleParams{
- UserID: payload.UserID,
- Owner: payload.Owner,
- Contact: payload.Contact,
- Content: payload.Content,
- SIM: payload.SIM,
- Encrypted: payload.Encrypted,
- Source: event.Source(),
- MessageID: payload.MessageID,
+ UserID: payload.UserID,
+ Owner: payload.Owner,
+ Contact: payload.Contact,
+ Content: payload.Content,
+ SIM: payload.SIM,
+ Encrypted: payload.Encrypted,
+ Source: event.Source(),
+ MessageID: payload.MessageID,
+ ExactSendTime: payload.ExactSendTime,
+ ScheduledSendTime: payload.ScheduledSendTime,
}
if err := listener.service.Schedule(ctx, sendParams); err != nil {
@@ -165,3 +168,22 @@ func (listener *PhoneNotificationListener) onUserAccountDeleted(ctx context.Cont
return nil
}
+
+// onMessageAPIDeleted handles the events.MessageAPIDeleted event
+func (listener *PhoneNotificationListener) onMessageAPIDeleted(ctx context.Context, event cloudevents.Event) error {
+ ctx, span := listener.tracer.Start(ctx)
+ defer span.End()
+
+ var payload events.MessageAPIDeletedPayload
+ if err := event.DataAs(&payload); err != nil {
+ msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err := listener.service.DeleteByMessageID(ctx, payload.UserID, payload.MessageID); err != nil {
+ msg := fmt.Sprintf("cannot delete [entities.PhoneNotification] for user [%s] and message [%s] on [%s] event with ID [%s]", payload.UserID, payload.MessageID, event.Type(), event.ID())
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
diff --git a/api/pkg/listeners/websocket_listener.go b/api/pkg/listeners/websocket_listener.go
index 02e9a36a..2c0e2c17 100644
--- a/api/pkg/listeners/websocket_listener.go
+++ b/api/pkg/listeners/websocket_listener.go
@@ -32,9 +32,10 @@ func NewWebsocketListener(
}
return l, map[string]events.EventListener{
- events.EventTypePhoneUpdated: l.onPhoneUpdated,
- events.EventTypeMessagePhoneSent: l.onMessagePhoneSent,
- events.EventTypeMessageSendFailed: l.onMessagePhoneFailed,
+ events.EventTypePhoneUpdated: l.onPhoneUpdated,
+ events.EventTypeMessagePhoneSent: l.onMessagePhoneSent,
+ events.EventTypeMessageSendFailed: l.onMessagePhoneFailed,
+ events.EventTypeMessagePhoneReceived: l.onMessagePhoneReceived,
}
}
@@ -57,6 +58,25 @@ func (listener *WebsocketListener) onMessagePhoneSent(ctx context.Context, event
return nil
}
+// onMessagePhoneReceived handles the events.EventTypeMessagePhoneReceived event
+func (listener *WebsocketListener) onMessagePhoneReceived(ctx context.Context, event cloudevents.Event) error {
+ ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger)
+ defer span.End()
+
+ var payload events.MessagePhoneReceivedPayload
+ if err := event.DataAs(&payload); err != nil {
+ msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload)
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event.ID()); err != nil {
+ msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID)
+ return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
// onMessagePhoneFailed handles the events.EventTypeMessageSendFailed event
func (listener *WebsocketListener) onMessagePhoneFailed(ctx context.Context, event cloudevents.Event) error {
ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger)
diff --git a/api/pkg/middlewares/api_key_auth_middleware.go b/api/pkg/middlewares/api_key_auth_middleware.go
index 17ac4335..d971c94a 100644
--- a/api/pkg/middlewares/api_key_auth_middleware.go
+++ b/api/pkg/middlewares/api_key_auth_middleware.go
@@ -33,7 +33,6 @@ func APIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository
}
c.Locals(ContextKeyAuthUserID, authUser)
- ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID))
return c.Next()
}
}
diff --git a/api/pkg/middlewares/bearer_api_key_auth_middleware.go b/api/pkg/middlewares/bearer_api_key_auth_middleware.go
index 2b1dc1c2..16d9ac5e 100644
--- a/api/pkg/middlewares/bearer_api_key_auth_middleware.go
+++ b/api/pkg/middlewares/bearer_api_key_auth_middleware.go
@@ -15,11 +15,9 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo
logger = logger.WithService("middlewares.APIKeyAuth")
return func(c *fiber.Ctx) error {
- ctx, span := tracer.StartFromFiberCtx(c, "middlewares.APIKeyAuth")
+ ctx, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger, "middlewares.APIKeyAuth")
defer span.End()
- ctxLogger := tracer.CtxLogger(logger, span)
-
apiKey := strings.TrimSpace(strings.Replace(c.Get(authHeaderBearer), bearerScheme, "", 1))
if len(apiKey) == 0 {
span.AddEvent(fmt.Sprintf("the request header has no [%s] api key", authHeaderAPIKey))
@@ -33,9 +31,6 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo
}
c.Locals(ContextKeyAuthUserID, authUser)
-
- ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID))
-
return c.Next()
}
}
diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go
index 7df1ca3a..ffd29f0d 100644
--- a/api/pkg/middlewares/bearer_auth_middleware.go
+++ b/api/pkg/middlewares/bearer_auth_middleware.go
@@ -46,8 +46,6 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au
}
c.Locals(ContextKeyAuthUserID, authUser)
-
- ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID))
return c.Next()
}
}
diff --git a/api/pkg/middlewares/http_request_logger_middleware.go b/api/pkg/middlewares/http_request_logger_middleware.go
index bc0146f0..75ddcae2 100644
--- a/api/pkg/middlewares/http_request_logger_middleware.go
+++ b/api/pkg/middlewares/http_request_logger_middleware.go
@@ -2,6 +2,7 @@ package middlewares
import (
"fmt"
+ "slices"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/gofiber/fiber/v2"
@@ -18,17 +19,12 @@ func HTTPRequestLogger(tracer telemetry.Tracer, logger telemetry.Logger) fiber.H
_, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger)
defer span.End()
- ctxLogger.WithString("http.method", c.Method()).
- WithString("http.path", c.Path()).
- WithString("client.version", c.Get(clientVersionHeader)).
- Trace(fmt.Sprintf("%s %s", c.Method(), c.OriginalURL()))
-
response := c.Next()
statusCode := c.Response().StatusCode()
span.AddEvent(fmt.Sprintf("finished handling request with traceID: [%s], statusCode: [%d]", span.SpanContext().TraceID().String(), statusCode))
- if statusCode >= 300 && len(c.Request().Body()) > 0 {
- ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))))
+ if statusCode >= 300 && len(c.Request().Body()) > 0 && !slices.Contains([]int{401, 402}, statusCode) {
+ ctxLogger.WithString("client.version", c.Get(clientVersionHeader)).Warn(stacktrace.NewError(fmt.Sprintf("http.status [%d], body [%s]", statusCode, string(c.Request().Body()))))
}
return response
diff --git a/api/pkg/middlewares/phone_api_key_auth_middleware.go b/api/pkg/middlewares/phone_api_key_auth_middleware.go
index dc19c3b8..72bc75ae 100644
--- a/api/pkg/middlewares/phone_api_key_auth_middleware.go
+++ b/api/pkg/middlewares/phone_api_key_auth_middleware.go
@@ -31,7 +31,6 @@ func PhoneAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, repositor
}
c.Locals(ContextKeyAuthUserID, authUser)
- ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID))
return c.Next()
}
}
diff --git a/api/pkg/repositories/attachment_repository.go b/api/pkg/repositories/attachment_repository.go
new file mode 100644
index 00000000..11d80e20
--- /dev/null
+++ b/api/pkg/repositories/attachment_repository.go
@@ -0,0 +1,99 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// AttachmentRepository is the interface for storing and retrieving message attachments
+type AttachmentRepository interface {
+ // Upload stores attachment data at the given path with the specified content type
+ Upload(ctx context.Context, path string, data []byte, contentType string) error
+ // Download retrieves attachment data from the given path
+ Download(ctx context.Context, path string) ([]byte, error)
+ // Delete removes an attachment at the given path
+ Delete(ctx context.Context, path string) error
+}
+
+// contentTypeExtensions maps MIME types to file extensions
+var contentTypeExtensions = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "video/mp4": ".mp4",
+ "video/3gpp": ".3gp",
+ "audio/mpeg": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/amr": ".amr",
+ "application/pdf": ".pdf",
+ "text/vcard": ".vcf",
+ "text/x-vcard": ".vcf",
+}
+
+// extensionContentTypes is the reverse map from file extensions to canonical MIME types
+var extensionContentTypes = map[string]string{
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".bmp": "image/bmp",
+ ".mp4": "video/mp4",
+ ".3gp": "video/3gpp",
+ ".mp3": "audio/mpeg",
+ ".ogg": "audio/ogg",
+ ".amr": "audio/amr",
+ ".pdf": "application/pdf",
+ ".vcf": "text/vcard",
+}
+
+// AllowedContentTypes returns the set of allowed MIME types for attachments
+func AllowedContentTypes() map[string]bool {
+ allowed := make(map[string]bool, len(contentTypeExtensions))
+ for ct := range contentTypeExtensions {
+ allowed[ct] = true
+ }
+ return allowed
+}
+
+// ExtensionFromContentType returns the file extension for a MIME content type.
+// Returns ".bin" if the content type is not recognized.
+func ExtensionFromContentType(contentType string) string {
+ if ext, ok := contentTypeExtensions[contentType]; ok {
+ return ext
+ }
+ return ".bin"
+}
+
+// ContentTypeFromExtension returns the MIME content type for a file extension.
+// Returns "application/octet-stream" if the extension is not recognized.
+func ContentTypeFromExtension(ext string) string {
+ if ct, ok := extensionContentTypes[ext]; ok {
+ return ct
+ }
+ return "application/octet-stream"
+}
+
+// SanitizeFilename removes path separators and traversal sequences from a filename.
+// Returns "attachment-{index}" if the sanitized name is empty.
+func SanitizeFilename(name string, index int) string {
+ name = strings.TrimSuffix(name, filepath.Ext(name))
+
+ var builder strings.Builder
+ for _, r := range name {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ builder.WriteRune(r)
+ } else if r == ' ' {
+ builder.WriteRune('-')
+ }
+ }
+ name = strings.Trim(builder.String(), "-")
+
+ if name == "" {
+ return fmt.Sprintf("attachment-%d", index)
+ }
+ return name
+}
diff --git a/api/pkg/repositories/attachment_repository_test.go b/api/pkg/repositories/attachment_repository_test.go
new file mode 100644
index 00000000..1b29fa68
--- /dev/null
+++ b/api/pkg/repositories/attachment_repository_test.go
@@ -0,0 +1,63 @@
+package repositories
+
+import "testing"
+
+func TestExtensionFromContentType(t *testing.T) {
+ tests := []struct {
+ contentType string
+ expected string
+ }{
+ {"image/jpeg", ".jpg"},
+ {"image/png", ".png"},
+ {"image/gif", ".gif"},
+ {"image/webp", ".webp"},
+ {"image/bmp", ".bmp"},
+ {"video/mp4", ".mp4"},
+ {"video/3gpp", ".3gp"},
+ {"audio/mpeg", ".mp3"},
+ {"audio/ogg", ".ogg"},
+ {"audio/amr", ".amr"},
+ {"application/pdf", ".pdf"},
+ {"text/vcard", ".vcf"},
+ {"text/x-vcard", ".vcf"},
+ {"application/octet-stream", ".bin"},
+ {"unknown/type", ".bin"},
+ {"", ".bin"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.contentType, func(t *testing.T) {
+ got := ExtensionFromContentType(tt.contentType)
+ if got != tt.expected {
+ t.Errorf("ExtensionFromContentType(%q) = %q, want %q", tt.contentType, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSanitizeFilename(t *testing.T) {
+ tests := []struct {
+ name string
+ index int
+ expected string
+ }{
+ {"photo.jpg", 0, "photo"},
+ {"../../etc/passwd", 0, "etcpasswd"},
+ {"hello/world\\test", 0, "helloworldtest"},
+ {"normal_file", 0, "normal_file"},
+ {"", 0, "attachment-0"},
+ {" ", 0, "attachment-0"},
+ {"...", 1, "attachment-1"},
+ {"My Photo", 0, "My-Photo"},
+ {"file name with spaces.png", 0, "file-name-with-spaces"},
+ {"UPPER_CASE", 0, "UPPER_CASE"},
+ {"special!@#chars", 0, "specialchars"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SanitizeFilename(tt.name, tt.index)
+ if got != tt.expected {
+ t.Errorf("SanitizeFilename(%q, %d) = %q, want %q", tt.name, tt.index, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/api/pkg/repositories/billing_usage_repository.go b/api/pkg/repositories/billing_usage_repository.go
index e9c4ffdb..e5973538 100644
--- a/api/pkg/repositories/billing_usage_repository.go
+++ b/api/pkg/repositories/billing_usage_repository.go
@@ -21,6 +21,6 @@ type BillingUsageRepository interface {
// GetHistory returns past billing usage by entities.UserID
GetHistory(ctx context.Context, userID entities.UserID, params IndexParams) (*[]entities.BillingUsage, error)
- // DeleteForUser deletes all billing usage for an entities.UserID
+ // DeleteAllForUser deletes all billing usage for an entities.UserID
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
}
diff --git a/api/pkg/repositories/billing_usage_repository_test.go b/api/pkg/repositories/billing_usage_repository_test.go
new file mode 100644
index 00000000..93a39d6f
--- /dev/null
+++ b/api/pkg/repositories/billing_usage_repository_test.go
@@ -0,0 +1,98 @@
+package repositories
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestComputeBillingCycle(t *testing.T) {
+ tests := []struct {
+ name string
+ now time.Time
+ anchorDay int
+ wantStart time.Time
+ wantEnd time.Time
+ }{
+ {
+ name: "anchor day 1 (same as calendar month)",
+ now: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC),
+ anchorDay: 1,
+ wantStart: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 5, 31, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 15, now is after anchor",
+ now: time.Date(2026, 5, 20, 10, 0, 0, 0, time.UTC),
+ anchorDay: 15,
+ wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 15, now is before anchor",
+ now: time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC),
+ anchorDay: 15,
+ wantStart: time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 5, 14, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 15, now is exactly on anchor",
+ now: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
+ anchorDay: 15,
+ wantStart: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 6, 14, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 31 in February (clamped to 28)",
+ now: time.Date(2026, 2, 15, 10, 0, 0, 0, time.UTC),
+ anchorDay: 31,
+ wantStart: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 2, 27, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 31 in March (not clamped)",
+ now: time.Date(2026, 3, 31, 10, 0, 0, 0, time.UTC),
+ anchorDay: 31,
+ wantStart: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 4, 29, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 29 in February leap year",
+ now: time.Date(2024, 2, 29, 10, 0, 0, 0, time.UTC),
+ anchorDay: 29,
+ wantStart: time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2024, 3, 28, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "anchor day 29 in February non-leap year (clamped to 28)",
+ now: time.Date(2026, 2, 28, 10, 0, 0, 0, time.UTC),
+ anchorDay: 29,
+ wantStart: time.Date(2026, 2, 28, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 3, 28, 23, 59, 59, 0, time.UTC),
+ },
+ {
+ name: "year boundary: anchor day 20, now is Jan 5",
+ now: time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC),
+ anchorDay: 20,
+ wantStart: time.Date(2025, 12, 20, 0, 0, 0, 0, time.UTC),
+ wantEnd: time.Date(2026, 1, 19, 23, 59, 59, 0, time.UTC),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ start, end := computeBillingCycle(tt.now, tt.anchorDay)
+ assert.Equal(t, tt.wantStart, start)
+ assert.Equal(t, tt.wantEnd, end)
+ })
+ }
+}
+
+func TestDaysInMonth(t *testing.T) {
+ assert.Equal(t, 31, daysInMonth(2026, time.January))
+ assert.Equal(t, 28, daysInMonth(2026, time.February))
+ assert.Equal(t, 29, daysInMonth(2024, time.February))
+ assert.Equal(t, 30, daysInMonth(2026, time.April))
+ assert.Equal(t, 31, daysInMonth(2026, time.December))
+}
diff --git a/api/pkg/repositories/google_cloud_storage_attachment_repository.go b/api/pkg/repositories/google_cloud_storage_attachment_repository.go
new file mode 100644
index 00000000..d1e0eb92
--- /dev/null
+++ b/api/pkg/repositories/google_cloud_storage_attachment_repository.go
@@ -0,0 +1,92 @@
+package repositories
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "cloud.google.com/go/storage"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// GoogleCloudStorageAttachmentRepository stores attachments in Google Cloud Storage
+type GoogleCloudStorageAttachmentRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ client *storage.Client
+ bucket string
+}
+
+// NewGoogleCloudStorageAttachmentRepository creates a new GoogleCloudStorageAttachmentRepository
+func NewGoogleCloudStorageAttachmentRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ client *storage.Client,
+ bucket string,
+) *GoogleCloudStorageAttachmentRepository {
+ return &GoogleCloudStorageAttachmentRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &GoogleCloudStorageAttachmentRepository{})),
+ tracer: tracer,
+ client: client,
+ bucket: bucket,
+ }
+}
+
+// Upload stores attachment data at the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Upload(ctx context.Context, path string, data []byte, contentType string) error {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ writer := s.client.Bucket(s.bucket).Object(path).NewWriter(ctx)
+ writer.ContentType = contentType
+
+ if _, err := writer.Write(data); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot write attachment to GCS path [%s]", path)))
+ }
+
+ if err := writer.Close(); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot close GCS writer for path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("uploaded attachment to GCS path [%s/%s] with size [%d]", s.bucket, path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ reader, err := s.client.Bucket(s.bucket).Object(path).NewReader(ctx)
+ if err != nil {
+ msg := fmt.Sprintf("cannot open GCS reader for path [%s]", path)
+ if errors.Is(err, storage.ErrObjectNotExist) {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
+ }
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ defer reader.Close()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot read attachment from GCS path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("downloaded attachment from GCS path [%s/%s] with size [%d]", s.bucket, path, len(data)))
+ return data, nil
+}
+
+// Delete removes an attachment at the given path in GCS
+func (s *GoogleCloudStorageAttachmentRepository) Delete(ctx context.Context, path string) error {
+ ctx, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ if err := s.client.Bucket(s.bucket).Object(path).Delete(ctx); err != nil {
+ return s.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete GCS object at path [%s]", path)))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("deleted attachment from GCS path [%s/%s]", s.bucket, path))
+ return nil
+}
diff --git a/api/pkg/repositories/gorm_billing_usage_repository.go b/api/pkg/repositories/gorm_billing_usage_repository.go
index 4df8e65d..33db958d 100644
--- a/api/pkg/repositories/gorm_billing_usage_repository.go
+++ b/api/pkg/repositories/gorm_billing_usage_repository.go
@@ -10,7 +10,6 @@ import (
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm"
"github.com/google/uuid"
- "github.com/jinzhu/now"
"github.com/palantir/stacktrace"
"gorm.io/gorm"
)
@@ -57,12 +56,17 @@ func (repository *gormBillingUsageRepository) RegisterSentMessage(ctx context.Co
func(tx *gorm.DB) error {
result := tx.WithContext(ctx).
Model(&entities.BillingUsage{}).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
Where("user_id = ?", userID).
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
UpdateColumn("sent_messages", gorm.Expr("sent_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- return tx.Create(repository.createBillingUsage(userID, timestamp, 1, 0)).Error
+ usage, err := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 1, 0)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -78,12 +82,17 @@ func (repository *gormBillingUsageRepository) RegisterReceivedMessage(ctx contex
func(tx *gorm.DB) error {
result := tx.WithContext(ctx).
Model(&entities.BillingUsage{}).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
Where("user_id = ?", userID).
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
UpdateColumn("received_messages", gorm.Expr("received_messages + ?", 1))
if result.Error == nil && result.RowsAffected == 0 {
- return tx.Create(repository.createBillingUsage(userID, timestamp, 0, 1)).Error
+ usage, err := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 0, 1)
+ if err != nil {
+ return err
+ }
+ return tx.Create(usage).Error
}
return result.Error
},
@@ -96,29 +105,36 @@ func (repository *gormBillingUsageRepository) GetCurrent(ctx context.Context, us
defer span.End()
timestamp := time.Now().UTC()
- usage := repository.createBillingUsage(userID, timestamp, 0, 0)
+ var usage entities.BillingUsage
err := crdbgorm.ExecuteTx(ctx, repository.db, nil,
func(tx *gorm.DB) error {
- loadedUsage := &entities.BillingUsage{}
result := tx.WithContext(ctx).
Where("user_id = ?", userID).
- Where("start_timestamp = ?", now.New(timestamp).BeginningOfMonth()).
- First(&loadedUsage)
+ Where("start_timestamp <= ?", timestamp).
+ Where("end_timestamp >= ?", timestamp).
+ First(&usage)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return tx.WithContext(ctx).Create(usage).Error
+ newUsage, createErr := repository.createBillingUsageForUser(ctx, tx, userID, timestamp, 0, 0)
+ if createErr != nil {
+ return createErr
+ }
+ if err := tx.WithContext(ctx).Create(newUsage).Error; err != nil {
+ return err
+ }
+ usage = *newUsage
+ return nil
}
- *usage = *loadedUsage
return result.Error
},
)
if err != nil {
- return usage, stacktrace.Propagate(err, fmt.Sprintf("cannot load billing usage for user [%s]", userID))
+ return &usage, stacktrace.Propagate(err, fmt.Sprintf("cannot load billing usage for user [%s]", userID))
}
- return usage, err
+ return &usage, nil
}
// GetHistory returns past billing usage by entities.UserID
@@ -126,11 +142,12 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us
ctx, span := repository.tracer.Start(ctx)
defer span.End()
+ timestamp := time.Now().UTC()
usages := new([]entities.BillingUsage)
err := repository.db.WithContext(ctx).
Where("user_id = ?", userID).
- Where("start_timestamp != ?", now.BeginningOfMonth()).
+ Where("end_timestamp < ?", timestamp).
Order("start_timestamp DESC").
Limit(params.Limit).
Offset(params.Skip).
@@ -141,16 +158,59 @@ func (repository *gormBillingUsageRepository) GetHistory(ctx context.Context, us
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
- return usages, err
+ return usages, nil
}
-func (repository *gormBillingUsageRepository) createBillingUsage(userID entities.UserID, timestamp time.Time, sent uint, received uint) *entities.BillingUsage {
+// createBillingUsageForUser loads the user to determine anchor day and computes cycle boundaries.
+// It accepts a tx to ensure the user read is part of the same transaction snapshot.
+func (repository *gormBillingUsageRepository) createBillingUsageForUser(ctx context.Context, tx *gorm.DB, userID entities.UserID, timestamp time.Time, sent uint, received uint) (*entities.BillingUsage, error) {
+ user := new(entities.User)
+ if err := tx.WithContext(ctx).First(user, userID).Error; err != nil {
+ return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] to compute billing cycle", userID))
+ }
+
+ start, end := computeBillingCycle(timestamp, user.GetBillingAnchorDay())
+
return &entities.BillingUsage{
ID: uuid.New(),
UserID: userID,
SentMessages: sent,
ReceivedMessages: received,
- StartTimestamp: now.New(timestamp).BeginningOfMonth(),
- EndTimestamp: now.New(timestamp).EndOfMonth(),
+ StartTimestamp: start,
+ EndTimestamp: end,
+ }, nil
+}
+
+// computeBillingCycle returns the start and end timestamps of the billing cycle
+// that contains `now`, given the user's anchor day (1–31). The anchor day is
+// dynamically clamped to the number of days in the relevant month.
+func computeBillingCycle(now time.Time, anchorDay int) (start, end time.Time) {
+ clampedDay := min(anchorDay, daysInMonth(now.Year(), now.Month()))
+
+ if now.Day() >= clampedDay {
+ start = time.Date(now.Year(), now.Month(), clampedDay, 0, 0, 0, 0, time.UTC)
+ } else {
+ prev := now.AddDate(0, -1, 0)
+ prevClamped := min(anchorDay, daysInMonth(prev.Year(), prev.Month()))
+ start = time.Date(prev.Year(), prev.Month(), prevClamped, 0, 0, 0, 0, time.UTC)
}
+
+ nextMonth := start.Month() + 1
+ nextYear := start.Year()
+ if nextMonth > 12 {
+ nextMonth = 1
+ nextYear++
+ }
+
+ nextClamped := min(anchorDay, daysInMonth(nextYear, nextMonth))
+ nextCycleStart := time.Date(nextYear, nextMonth, nextClamped, 0, 0, 0, 0, time.UTC)
+
+ end = nextCycleStart.Add(-time.Second)
+
+ return start, end
+}
+
+// daysInMonth returns the number of days in the given month/year.
+func daysInMonth(year int, month time.Month) int {
+ return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
diff --git a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go
index b88c15e2..e6f5aee5 100644
--- a/api/pkg/repositories/gorm_heartbeat_monitor_repository.go
+++ b/api/pkg/repositories/gorm_heartbeat_monitor_repository.go
@@ -22,6 +22,19 @@ type gormHeartbeatMonitorRepository struct {
db *gorm.DB
}
+// NewGormHeartbeatMonitorRepository creates the GORM version of the HeartbeatMonitorRepository
+func NewGormHeartbeatMonitorRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ db *gorm.DB,
+) HeartbeatMonitorRepository {
+ return &gormHeartbeatMonitorRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})),
+ tracer: tracer,
+ db: db,
+ }
+}
+
func (repository *gormHeartbeatMonitorRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
ctx, span := repository.tracer.Start(ctx)
defer span.End()
@@ -30,7 +43,6 @@ func (repository *gormHeartbeatMonitorRepository) DeleteAllForUser(ctx context.C
msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
-
return nil
}
@@ -98,19 +110,6 @@ func (repository *gormHeartbeatMonitorRepository) Delete(ctx context.Context, us
return nil
}
-// NewGormHeartbeatMonitorRepository creates the GORM version of the HeartbeatMonitorRepository
-func NewGormHeartbeatMonitorRepository(
- logger telemetry.Logger,
- tracer telemetry.Tracer,
- db *gorm.DB,
-) HeartbeatMonitorRepository {
- return &gormHeartbeatMonitorRepository{
- logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})),
- tracer: tracer,
- db: db,
- }
-}
-
// Index entities.Message between 2 parties
func (repository *gormHeartbeatMonitorRepository) Index(ctx context.Context, userID entities.UserID, owner string, params IndexParams) (*[]entities.Heartbeat, error) {
ctx, span := repository.tracer.Start(ctx)
@@ -158,7 +157,6 @@ func (repository *gormHeartbeatMonitorRepository) Load(ctx context.Context, user
Where("user_id = ?", userID).
Where("owner = ?", owner).
First(&phone).Error
-
if errors.Is(err, gorm.ErrRecordNotFound) {
msg := fmt.Sprintf("heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, owner)
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
@@ -188,7 +186,7 @@ func (repository *gormHeartbeatMonitorRepository) Exists(ctx context.Context, us
Where("id = ?", monitorID).
Find(&exists).Error
if err != nil {
- msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and montiorID [%s]", userID, monitorID)
+ msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and montior ID [%s]", userID, monitorID)
return exists, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
diff --git a/api/pkg/repositories/gorm_heartbeat_repository.go b/api/pkg/repositories/gorm_heartbeat_repository.go
index 8e763f60..e9ddf7ce 100644
--- a/api/pkg/repositories/gorm_heartbeat_repository.go
+++ b/api/pkg/repositories/gorm_heartbeat_repository.go
@@ -36,7 +36,8 @@ func (repository *gormHeartbeatRepository) DeleteAllForUser(ctx context.Context,
ctx, span := repository.tracer.Start(ctx)
defer span.End()
- if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error; err != nil {
+ err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.Heartbeat{}).Error
+ if err != nil {
msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)
return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
@@ -85,7 +86,8 @@ func (repository *gormHeartbeatRepository) Index(ctx context.Context, userID ent
}
heartbeats := new([]entities.Heartbeat)
- if err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error; err != nil {
+ err := query.Order("timestamp DESC").Limit(params.Limit).Offset(params.Skip).Find(&heartbeats).Error
+ if err != nil {
msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go
index 607af44e..03237566 100644
--- a/api/pkg/repositories/gorm_message_repository.go
+++ b/api/pkg/repositories/gorm_message_repository.go
@@ -176,6 +176,37 @@ func (repository *gormMessageRepository) Search(ctx context.Context, userID enti
return messages, nil
}
+// GetBulkMessages fetches the last bulk message summaries for a user
+func (repository *gormMessageRepository) GetBulkMessages(ctx context.Context, userID entities.UserID, limit int) ([]*entities.BulkMessage, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ orders := make([]*entities.BulkMessage, 0)
+ err := repository.db.WithContext(ctx).Raw(`
+ SELECT
+ request_id,
+ COUNT(*) as total,
+ COUNT(*) FILTER (WHERE status = 'scheduled') as scheduled_count,
+ COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
+ COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
+ COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
+ COUNT(*) FILTER (WHERE status = 'sent') as sent_count,
+ COUNT(*) FILTER (WHERE status = 'delivered') as delivered_count,
+ MIN(created_at) as created_at
+ FROM messages
+ WHERE user_id = ? AND request_id LIKE 'bulk-%'
+ GROUP BY request_id
+ ORDER BY MIN(created_at) DESC
+ LIMIT ?
+ `, userID, limit).Scan(&orders).Error
+ if err != nil {
+ msg := fmt.Sprintf("cannot fetch bulk message orders for user [%s]", userID)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return orders, nil
+}
+
// Store a new entities.Message
func (repository *gormMessageRepository) Store(ctx context.Context, message *entities.Message) error {
ctx, span := repository.tracer.Start(ctx)
diff --git a/api/pkg/repositories/gorm_message_send_schedule_repository.go b/api/pkg/repositories/gorm_message_send_schedule_repository.go
new file mode 100644
index 00000000..54e58843
--- /dev/null
+++ b/api/pkg/repositories/gorm_message_send_schedule_repository.go
@@ -0,0 +1,168 @@
+package repositories
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/google/uuid"
+ "github.com/palantir/stacktrace"
+ "gorm.io/gorm"
+)
+
+// gormMessageSendScheduleRepository persists and loads entities.MessageSendSchedule using GORM.
+type gormMessageSendScheduleRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ db *gorm.DB
+}
+
+// NewGormMessageSendScheduleRepository creates a new GORM-backed MessageSendScheduleRepository.
+func NewGormMessageSendScheduleRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ db *gorm.DB,
+) MessageSendScheduleRepository {
+ return &gormMessageSendScheduleRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &gormMessageSendScheduleRepository{})),
+ tracer: tracer,
+ db: db,
+ }
+}
+
+// Store saves a new message send schedule.
+func (r *gormMessageSendScheduleRepository) Store(
+ ctx context.Context,
+ schedule *entities.MessageSendSchedule,
+) error {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ if err := r.db.WithContext(ctx).Create(schedule).Error; err != nil {
+ return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot store send schedule [%s]", schedule.ID))
+ }
+
+ return nil
+}
+
+// Update persists changes to an existing message send schedule.
+func (r *gormMessageSendScheduleRepository) Update(
+ ctx context.Context,
+ schedule *entities.MessageSendSchedule,
+) error {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ if err := r.db.WithContext(ctx).Save(schedule).Error; err != nil {
+ return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot update send schedule [%s]", schedule.ID))
+ }
+
+ return nil
+}
+
+// Load fetches a message send schedule by user ID and schedule ID.
+func (r *gormMessageSendScheduleRepository) Load(
+ ctx context.Context,
+ userID entities.UserID,
+ scheduleID uuid.UUID,
+) (*entities.MessageSendSchedule, error) {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ item := new(entities.MessageSendSchedule)
+ err := r.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Where("id = ?", scheduleID).
+ First(item).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, r.tracer.WrapErrorSpan(
+ span,
+ stacktrace.PropagateWithCode(err, ErrCodeNotFound, "send schedule [%s] not found for user with ID [%s]", scheduleID, userID),
+ )
+ }
+ if err != nil {
+ return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot load send schedule [%s]", scheduleID))
+ }
+
+ return item, nil
+}
+
+// Index lists all message send schedules owned by the given user.
+func (r *gormMessageSendScheduleRepository) Index(
+ ctx context.Context,
+ userID entities.UserID,
+) ([]entities.MessageSendSchedule, error) {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ items := make([]entities.MessageSendSchedule, 0)
+ err := r.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Order("created_at DESC").
+ Find(&items).Error
+ if err != nil {
+ return nil, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot index send schedules for user [%s]", userID))
+ }
+
+ return items, nil
+}
+
+// Delete removes a message send schedule owned by the given user.
+func (r *gormMessageSendScheduleRepository) Delete(
+ ctx context.Context,
+ userID entities.UserID,
+ scheduleID uuid.UUID,
+) error {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ err := r.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Where("id = ?", scheduleID).
+ Delete(&entities.MessageSendSchedule{}).Error
+ if err != nil {
+ return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete send schedule [%s]", scheduleID))
+ }
+
+ return nil
+}
+
+// DeleteAllForUser removes all message send schedules owned by the given user.
+func (r *gormMessageSendScheduleRepository) DeleteAllForUser(
+ ctx context.Context,
+ userID entities.UserID,
+) error {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ err := r.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Delete(&entities.MessageSendSchedule{}).Error
+ if err != nil {
+ return r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot delete send schedules for user [%s]", userID))
+ }
+
+ return nil
+}
+
+// CountByUser returns the number of schedules owned by a user.
+func (r *gormMessageSendScheduleRepository) CountByUser(
+ ctx context.Context,
+ userID entities.UserID,
+) (int, error) {
+ ctx, span := r.tracer.Start(ctx)
+ defer span.End()
+
+ var count int64
+ err := r.db.WithContext(ctx).
+ Model(&entities.MessageSendSchedule{}).
+ Where("user_id = ?", userID).
+ Count(&count).Error
+ if err != nil {
+ return 0, r.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot count send schedules for user [%s]", userID))
+ }
+
+ return int(count), nil
+}
diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go
index 3b1bb306..68692a04 100644
--- a/api/pkg/repositories/gorm_phone_api_key_repository.go
+++ b/api/pkg/repositories/gorm_phone_api_key_repository.go
@@ -61,6 +61,23 @@ WHERE user_id = ? AND array_position(phone_ids, ?) IS NOT NULL;
return nil
}
+// CountByUser returns the number of phone API keys owned by a user.
+func (repository *gormPhoneAPIKeyRepository) CountByUser(ctx context.Context, userID entities.UserID) (int, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ var count int64
+ err := repository.db.WithContext(ctx).
+ Model(&entities.PhoneAPIKey{}).
+ Where("user_id = ?", userID).
+ Count(&count).Error
+ if err != nil {
+ return 0, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, "cannot count phone API keys for user [%s]", userID))
+ }
+
+ return int(count), nil
+}
+
// Load an entities.PhoneAPIKey based on the entities.UserID
func (repository *gormPhoneAPIKeyRepository) Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) {
ctx, span := repository.tracer.Start(ctx)
@@ -98,7 +115,6 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context
defer span.End()
if authContext, found := repository.cache.Get(apiKey); found {
- ctxLogger.Info(fmt.Sprintf("cache hit for user with ID [%s] and phone API Key ID [%s]", authContext.ID, *authContext.PhoneAPIKeyID))
return authContext, nil
}
diff --git a/api/pkg/repositories/gorm_phone_notification_repository.go b/api/pkg/repositories/gorm_phone_notification_repository.go
index f491e4b3..e1136415 100644
--- a/api/pkg/repositories/gorm_phone_notification_repository.go
+++ b/api/pkg/repositories/gorm_phone_notification_repository.go
@@ -15,39 +15,70 @@ import (
"gorm.io/gorm"
)
-// gormPhoneNotificationRepository is responsible for persisting entities.PhoneNotification
+// gormPhoneNotificationRepository persists entities.PhoneNotification records.
type gormPhoneNotificationRepository struct {
logger telemetry.Logger
tracer telemetry.Tracer
db *gorm.DB
}
-// NewGormPhoneNotificationRepository creates the GORM version of the PhoneNotificationRepository
+// NewGormPhoneNotificationRepository creates a GORM-backed PhoneNotificationRepository.
func NewGormPhoneNotificationRepository(
logger telemetry.Logger,
tracer telemetry.Tracer,
db *gorm.DB,
) PhoneNotificationRepository {
return &gormPhoneNotificationRepository{
- logger: logger.WithService(fmt.Sprintf("%T", &gormHeartbeatRepository{})),
+ logger: logger.WithService(fmt.Sprintf("%T", &gormPhoneNotificationRepository{})),
tracer: tracer,
db: db,
}
}
-func (repository *gormPhoneNotificationRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
+// DeleteAllForUser deletes all phone notifications that belong to a user.
+func (repository *gormPhoneNotificationRepository) DeleteAllForUser(
+ ctx context.Context,
+ userID entities.UserID,
+) error {
ctx, span := repository.tracer.Start(ctx)
defer span.End()
- if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.PhoneNotification{}).Error; err != nil {
- msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.PhoneNotification{}, userID)
- return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ if err := repository.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Delete(&entities.PhoneNotification{}).Error; err != nil {
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ "cannot delete all [%T] for user with ID [%s]",
+ &entities.PhoneNotification{},
+ userID,
+ ),
+ )
}
return nil
}
-// UpdateStatus of an entities.PhoneNotification
+// DeleteByMessageID deletes all entities.PhoneNotification for a user and message ID.
+func (repository *gormPhoneNotificationRepository) DeleteByMessageID(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ err := repository.db.WithContext(ctx).
+ Where("user_id = ? AND message_id = ?", userID, messageID).
+ Delete(&entities.PhoneNotification{}).Error
+ if err != nil {
+ msg := fmt.Sprintf("cannot delete [%T] for user [%s] and message with ID [%s]", &entities.PhoneNotification{}, userID, messageID)
+ return repository.tracer.WrapErrorSpan(span,
+ stacktrace.Propagate(err, msg),
+ )
+ }
+
+ return nil
+}
+
+// UpdateStatus updates the status of a phone notification.
func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error {
ctx, span := repository.tracer.Start(ctx)
defer span.End()
@@ -58,71 +89,166 @@ func (repository *gormPhoneNotificationRepository) UpdateStatus(ctx context.Cont
Update("status", status).
Error
if err != nil {
- msg := fmt.Sprintf("cannot update notification [%s] with status [%s]", notificationID, status)
- return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ "cannot update notification [%s] with status [%s]",
+ notificationID,
+ status,
+ ),
+ )
}
return nil
}
-// Schedule a notification to be sent in the future
-func (repository *gormPhoneNotificationRepository) Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error {
- ctx, span := repository.tracer.Start(ctx)
+// Schedule stores a phone notification and calculates its final scheduled time.
+// The final time is determined by combining:
+// 1. the next allowed time from the message send schedule
+// 2. the phone send-rate limit derived from the latest scheduled notification
+func (repository *gormPhoneNotificationRepository) Schedule(
+ ctx context.Context,
+ messagesPerMinute uint,
+ schedule *entities.MessageSendSchedule,
+ notification *entities.PhoneNotification,
+) error {
+ ctx, span, _ := repository.tracer.StartWithLogger(ctx, repository.logger)
defer span.End()
+ now := time.Now().UTC()
+
if messagesPerMinute == 0 {
+ notification.ScheduledAt = repository.resolveScheduledAt(schedule, now)
return repository.insert(ctx, notification)
}
err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error {
lastNotification := new(entities.PhoneNotification)
+
err := tx.WithContext(ctx).
Where("phone_id = ?", notification.PhoneID).
Order("scheduled_at desc").
First(lastNotification).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- msg := fmt.Sprintf("cannot fetch last notification with phone ID [%s]", notification.PhoneID)
- return stacktrace.Propagate(err, msg)
+ return stacktrace.Propagate(
+ err,
+ "cannot fetch last notification with phone ID [%s]",
+ notification.PhoneID,
+ )
}
- notification.ScheduledAt = time.Now().UTC()
+ notification.ScheduledAt = repository.resolveScheduledAt(schedule, now)
+
if err == nil {
- notification.ScheduledAt = repository.maxTime(
- time.Now().UTC(),
- lastNotification.ScheduledAt.Add(time.Duration(60/messagesPerMinute)*time.Second),
+ rateLimitedAt := lastNotification.ScheduledAt.Add(
+ time.Duration(60/messagesPerMinute) * time.Second,
)
+
+ nextCandidate := repository.maxTime(notification.ScheduledAt, rateLimitedAt)
+ notification.ScheduledAt = repository.resolveScheduledAt(schedule, nextCandidate)
}
if err = tx.WithContext(ctx).Create(notification).Error; err != nil {
- msg := fmt.Sprintf("cannot create new notification with id [%s] and schedule [%s]", notification.ID, notification.ScheduledAt.String())
- return stacktrace.Propagate(err, msg)
+ return stacktrace.Propagate(
+ err,
+ "cannot create new notification with id [%s] and schedule [%s]",
+ notification.ID,
+ notification.ScheduledAt.String(),
+ )
}
+
return nil
})
if err != nil {
- msg := fmt.Sprintf("cannot schedule phone notification with ID [%s]", notification.ID)
- return stacktrace.Propagate(err, msg)
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ "cannot schedule phone notification with ID [%s]",
+ notification.ID,
+ ),
+ )
}
return nil
}
+// resolveScheduledAt returns the next time the notification is allowed to be sent.
+// If no schedule is attached, the provided time is returned unchanged in UTC.
+func (repository *gormPhoneNotificationRepository) resolveScheduledAt(
+ schedule *entities.MessageSendSchedule,
+ current time.Time,
+) time.Time {
+ if schedule == nil {
+ return current.UTC()
+ }
+
+ return schedule.ResolveScheduledAt(current)
+}
+
+// maxTime returns the greater of the two time.Time.
func (repository *gormPhoneNotificationRepository) maxTime(a, b time.Time) time.Time {
- if a.Unix() > b.Unix() {
+ if a.After(b) {
return a
}
return b
}
-func (repository *gormPhoneNotificationRepository) insert(ctx context.Context, notification *entities.PhoneNotification) error {
+// insert stores a single phone notification.
+func (repository *gormPhoneNotificationRepository) insert(
+ ctx context.Context,
+ notification *entities.PhoneNotification,
+) error {
ctx, span := repository.tracer.Start(ctx)
defer span.End()
- err := repository.db.WithContext(ctx).Create(notification).Error
- if err != nil {
- msg := fmt.Sprintf("cannot store notification with id [%s]", notification.ID)
- return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil {
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ "cannot store notification with id [%s]",
+ notification.ID,
+ ),
+ )
+ }
+
+ return nil
+}
+
+// ScheduleExact stores a phone notification with an exact ScheduledAt time.
+// It performs a dedupe check — if a pending notification for the same message already exists, it's a no-op.
+func (repository *gormPhoneNotificationRepository) ScheduleExact(
+ ctx context.Context,
+ notification *entities.PhoneNotification,
+) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ // Dedupe: check if a pending notification for this message already exists
+ var count int64
+ if err := repository.db.WithContext(ctx).
+ Model(&entities.PhoneNotification{}).
+ Where("message_id = ? AND status = ?", notification.MessageID, entities.PhoneNotificationStatusPending).
+ Count(&count).Error; err != nil {
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(err, "cannot check for existing notification for message [%s]", notification.MessageID),
+ )
}
+
+ if count > 0 {
+ return nil
+ }
+
+ if err := repository.db.WithContext(ctx).Create(notification).Error; err != nil {
+ return repository.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(err, "cannot create exact-time notification with id [%s]", notification.ID),
+ )
+ }
+
return nil
}
diff --git a/api/pkg/repositories/gorm_phone_repository.go b/api/pkg/repositories/gorm_phone_repository.go
index 40c29d30..a1f79ba8 100644
--- a/api/pkg/repositories/gorm_phone_repository.go
+++ b/api/pkg/repositories/gorm_phone_repository.go
@@ -50,6 +50,25 @@ func (repository *gormPhoneRepository) DeleteAllForUser(ctx context.Context, use
return nil
}
+// NullifyScheduleID sets MessageSendScheduleID to NULL for all phones referencing the given schedule
+func (repository *gormPhoneRepository) NullifyScheduleID(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ err := repository.db.WithContext(ctx).
+ Model(&entities.Phone{}).
+ Where("user_id = ?", userID).
+ Where("message_send_schedule_id = ?", scheduleID).
+ Update("message_send_schedule_id", nil).Error
+ if err != nil {
+ msg := fmt.Sprintf("cannot nullify message_send_schedule_id [%s] for user [%s]", scheduleID, userID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ repository.cache.Clear()
+ return nil
+}
+
// LoadByID loads a phone by ID
func (repository *gormPhoneRepository) LoadByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID) (*entities.Phone, error) {
ctx, span := repository.tracer.Start(ctx)
@@ -123,7 +142,6 @@ func (repository *gormPhoneRepository) Load(ctx context.Context, userID entities
defer span.End()
if phone, found := repository.cache.Get(repository.getCacheKey(userID, phoneNumber)); found {
- ctxLogger.Info(fmt.Sprintf("cache hit for [%T] with ID [%s]", phone, userID))
return phone, nil
}
diff --git a/api/pkg/repositories/gorm_user_repository.go b/api/pkg/repositories/gorm_user_repository.go
index 39cda887..a64e8ae0 100644
--- a/api/pkg/repositories/gorm_user_repository.go
+++ b/api/pkg/repositories/gorm_user_repository.go
@@ -65,8 +65,13 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e
}
user := new(entities.User)
+ var oldAPIKey string
err = crdbgorm.ExecuteTx(ctx, repository.db, nil,
func(tx *gorm.DB) error {
+ if err := tx.WithContext(ctx).Where("id = ?", userID).First(user).Error; err != nil {
+ return err
+ }
+ oldAPIKey = user.APIKey
return tx.WithContext(ctx).Model(user).
Clauses(clause.Returning{}).
Where("id = ?", userID).
@@ -78,6 +83,13 @@ func (repository *gormUserRepository) RotateAPIKey(ctx context.Context, userID e
return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
}
+ if err == nil && oldAPIKey != "" {
+ // Flush pending ristretto Set operations before Del to avoid a
+ // buffered Set re-adding the entry after removal.
+ repository.cache.Wait()
+ repository.cache.Del(oldAPIKey)
+ }
+
return user, nil
}
@@ -154,13 +166,16 @@ func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKe
defer span.End()
if authUser, found := repository.cache.Get(apiKey); found {
- ctxLogger.Info(fmt.Sprintf("cache hit for user with ID [%s]", authUser.ID))
+ if authUser.IsNoop() {
+ return authUser, repository.tracer.WrapErrorSpan(span, stacktrace.NewError(fmt.Sprintf("user with api key [%s] does not exist", apiKey)))
+ }
return authUser, nil
}
user := new(entities.User)
err := repository.db.WithContext(ctx).Where("api_key = ?", apiKey).First(user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
+ repository.cache.SetWithTTL(apiKey, entities.AuthContext{}, 1, 2*time.Hour)
msg := fmt.Sprintf("user with api key [%s] does not exist", apiKey)
return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
}
diff --git a/api/pkg/repositories/memory_attachment_repository.go b/api/pkg/repositories/memory_attachment_repository.go
new file mode 100644
index 00000000..65eadf2f
--- /dev/null
+++ b/api/pkg/repositories/memory_attachment_repository.go
@@ -0,0 +1,60 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// MemoryAttachmentRepository stores attachments in memory
+type MemoryAttachmentRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ data sync.Map
+}
+
+// NewMemoryAttachmentRepository creates a new MemoryAttachmentRepository
+func NewMemoryAttachmentRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+) *MemoryAttachmentRepository {
+ return &MemoryAttachmentRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &MemoryAttachmentRepository{})),
+ tracer: tracer,
+ }
+}
+
+// Upload stores attachment data at the given path
+func (s *MemoryAttachmentRepository) Upload(ctx context.Context, path string, data []byte, _ string) error {
+ _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ s.data.Store(path, data)
+ ctxLogger.Info(fmt.Sprintf("stored attachment at path [%s] with size [%d]", path, len(data)))
+ return nil
+}
+
+// Download retrieves attachment data from the given path
+func (s *MemoryAttachmentRepository) Download(ctx context.Context, path string) ([]byte, error) {
+ _, span, _ := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ value, ok := s.data.Load(path)
+ if !ok {
+ return nil, s.tracer.WrapErrorSpan(span, stacktrace.NewErrorWithCode(ErrCodeNotFound, fmt.Sprintf("attachment not found at path [%s]", path)))
+ }
+ return value.([]byte), nil
+}
+
+// Delete removes an attachment at the given path
+func (s *MemoryAttachmentRepository) Delete(ctx context.Context, path string) error {
+ _, span, ctxLogger := s.tracer.StartWithLogger(ctx, s.logger)
+ defer span.End()
+
+ s.data.Delete(path)
+ ctxLogger.Info(fmt.Sprintf("deleted attachment at path [%s]", path))
+ return nil
+}
diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go
index 3ad70015..c8f85fbb 100644
--- a/api/pkg/repositories/message_repository.go
+++ b/api/pkg/repositories/message_repository.go
@@ -27,6 +27,9 @@ type MessageRepository interface {
// Search entities.Message for a user
Search(ctx context.Context, userID entities.UserID, owners []string, types []entities.MessageType, statuses []entities.MessageStatus, params IndexParams) ([]*entities.Message, error)
+ // GetBulkMessages fetches the last bulk message summaries for a user
+ GetBulkMessages(ctx context.Context, userID entities.UserID, limit int) ([]*entities.BulkMessage, error)
+
// GetOutstanding fetches an entities.Message which is outstanding
GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error)
diff --git a/api/pkg/repositories/message_send_schedule_repository.go b/api/pkg/repositories/message_send_schedule_repository.go
new file mode 100644
index 00000000..82ef4518
--- /dev/null
+++ b/api/pkg/repositories/message_send_schedule_repository.go
@@ -0,0 +1,32 @@
+package repositories
+
+import (
+ "context"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/google/uuid"
+)
+
+// MessageSendScheduleRepository loads and persists entities.MessageSendSchedule.
+type MessageSendScheduleRepository interface {
+ // Store persists a new message send schedule.
+ Store(ctx context.Context, schedule *entities.MessageSendSchedule) error
+
+ // Update persists changes to an existing message send schedule.
+ Update(ctx context.Context, schedule *entities.MessageSendSchedule) error
+
+ // Load returns a message send schedule by user ID and schedule ID.
+ Load(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) (*entities.MessageSendSchedule, error)
+
+ // Index returns all message send schedules owned by a user.
+ Index(ctx context.Context, userID entities.UserID) ([]entities.MessageSendSchedule, error)
+
+ // Delete removes a message send schedule owned by a user.
+ Delete(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error
+
+ // DeleteAllForUser removes all message send schedules owned by a user.
+ DeleteAllForUser(ctx context.Context, userID entities.UserID) error
+
+ // CountByUser returns the number of schedules owned by a user.
+ CountByUser(ctx context.Context, userID entities.UserID) (int, error)
+}
diff --git a/api/pkg/repositories/mongo_heartbeat_monitor_repository.go b/api/pkg/repositories/mongo_heartbeat_monitor_repository.go
new file mode 100644
index 00000000..13200c07
--- /dev/null
+++ b/api/pkg/repositories/mongo_heartbeat_monitor_repository.go
@@ -0,0 +1,182 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// mongoHeartbeatMonitorRepository is responsible for persisting entities.HeartbeatMonitor in MongoDB
+type mongoHeartbeatMonitorRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ collection *mongo.Collection
+}
+
+// NewMongoHeartbeatMonitorRepository creates the MongoDB version of the HeartbeatMonitorRepository
+func NewMongoHeartbeatMonitorRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ db *mongo.Database,
+) HeartbeatMonitorRepository {
+ return &mongoHeartbeatMonitorRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &mongoHeartbeatMonitorRepository{})),
+ tracer: tracer,
+ collection: db.Collection(collectionHeartbeatMonitors),
+ }
+}
+
+func (repository *mongoHeartbeatMonitorRepository) Store(ctx context.Context, monitor *entities.HeartbeatMonitor) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ _, err := repository.collection.InsertOne(ctx, monitor)
+ if err != nil {
+ msg := fmt.Sprintf("cannot save heartbeat monitor with ID [%s]", monitor.ID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) Load(ctx context.Context, userID entities.UserID, phoneNumber string) (*entities.HeartbeatMonitor, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"user_id", string(userID)},
+ {"owner", phoneNumber},
+ }
+
+ var monitor entities.HeartbeatMonitor
+ err := repository.collection.FindOne(ctx, filter).Decode(&monitor)
+ if err == mongo.ErrNoDocuments {
+ msg := fmt.Sprintf("heartbeat monitor with userID [%s] and owner [%s] does not exist", userID, phoneNumber)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
+ }
+ if err != nil {
+ msg := fmt.Sprintf("cannot load heartbeat monitor with userID [%s] and owner [%s]", userID, phoneNumber)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return &monitor, nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) Exists(ctx context.Context, userID entities.UserID, monitorID uuid.UUID) (bool, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"user_id", string(userID)},
+ {"_id", monitorID.String()},
+ }
+
+ count, err := repository.collection.CountDocuments(ctx, filter)
+ if err != nil {
+ msg := fmt.Sprintf("cannot check if heartbeat monitor exists with userID [%s] and monitor ID [%s]", userID, monitorID)
+ return false, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return count > 0, nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) UpdateQueueID(ctx context.Context, monitorID uuid.UUID, queueID string) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{{"_id", monitorID.String()}}
+ update := bson.D{{"$set", bson.D{
+ {"queue_id", queueID},
+ {"updated_at", time.Now().UTC()},
+ }}}
+
+ _, err := repository.collection.UpdateOne(ctx, filter, update)
+ if err != nil {
+ msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s]", monitorID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) Delete(ctx context.Context, userID entities.UserID, phoneNumber string) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"user_id", string(userID)},
+ {"owner", phoneNumber},
+ }
+
+ _, err := repository.collection.DeleteMany(ctx, filter)
+ if err != nil {
+ msg := fmt.Sprintf("cannot delete heartbeat monitor with owner [%s] and userID [%s]", phoneNumber, userID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) UpdatePhoneOnline(ctx context.Context, userID entities.UserID, monitorID uuid.UUID, online bool) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"_id", monitorID.String()},
+ {"user_id", string(userID)},
+ }
+ update := bson.D{{"$set", bson.D{
+ {"phone_online", online},
+ {"updated_at", time.Now().UTC()},
+ }}}
+
+ _, err := repository.collection.UpdateOne(ctx, filter, update)
+ if err != nil {
+ msg := fmt.Sprintf("cannot update heartbeat monitor ID [%s] for user [%s]", monitorID, userID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+func (repository *mongoHeartbeatMonitorRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ _, err := repository.collection.DeleteMany(ctx, bson.D{{"user_id", string(userID)}})
+ if err != nil {
+ msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.HeartbeatMonitor{}, userID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
diff --git a/api/pkg/repositories/mongo_heartbeat_repository.go b/api/pkg/repositories/mongo_heartbeat_repository.go
new file mode 100644
index 00000000..d8a7839c
--- /dev/null
+++ b/api/pkg/repositories/mongo_heartbeat_repository.go
@@ -0,0 +1,135 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// mongoHeartbeatRepository is responsible for persisting entities.Heartbeat in MongoDB
+type mongoHeartbeatRepository struct {
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ collection *mongo.Collection
+}
+
+// NewMongoHeartbeatRepository creates the MongoDB version of the HeartbeatRepository
+func NewMongoHeartbeatRepository(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ db *mongo.Database,
+) HeartbeatRepository {
+ return &mongoHeartbeatRepository{
+ logger: logger.WithService(fmt.Sprintf("%T", &mongoHeartbeatRepository{})),
+ tracer: tracer,
+ collection: db.Collection(collectionHeartbeats),
+ }
+}
+
+func (repository *mongoHeartbeatRepository) Store(ctx context.Context, heartbeat *entities.Heartbeat) error {
+ ctx, span, _ := repository.tracer.StartWithLogger(ctx, repository.logger)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ _, err := repository.collection.InsertOne(ctx, heartbeat)
+ if err != nil {
+ msg := fmt.Sprintf("cannot save heartbeat with ID [%s]", heartbeat.ID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+func (repository *mongoHeartbeatRepository) Index(ctx context.Context, userID entities.UserID, owner string, params IndexParams) (*[]entities.Heartbeat, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"user_id", string(userID)},
+ {"owner", owner},
+ }
+
+ if len(params.Query) > 0 {
+ filter = append(filter, bson.E{"version", bson.D{{"$regex", params.Query}, {"$options", "i"}}})
+ }
+
+ opts := options.Find().
+ SetSort(bson.D{{"timestamp", -1}}).
+ SetSkip(int64(params.Skip)).
+ SetLimit(int64(params.Limit))
+
+ cursor, err := repository.collection.Find(ctx, filter, opts)
+ if err != nil {
+ msg := fmt.Sprintf("cannot fetch heartbeats with owner [%s] and params [%+#v]", owner, params)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ defer cursor.Close(ctx)
+
+ var heartbeats []entities.Heartbeat
+ if err = cursor.All(ctx, &heartbeats); err != nil {
+ msg := fmt.Sprintf("cannot decode heartbeats for owner [%s]", owner)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if heartbeats == nil {
+ heartbeats = make([]entities.Heartbeat, 0)
+ }
+
+ return &heartbeats, nil
+}
+
+func (repository *mongoHeartbeatRepository) Last(ctx context.Context, userID entities.UserID, owner string) (*entities.Heartbeat, error) {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ filter := bson.D{
+ {"user_id", string(userID)},
+ {"owner", owner},
+ }
+
+ opts := options.FindOne().SetSort(bson.D{{"timestamp", -1}})
+
+ var heartbeat entities.Heartbeat
+ err := repository.collection.FindOne(ctx, filter, opts).Decode(&heartbeat)
+ if err == mongo.ErrNoDocuments {
+ msg := fmt.Sprintf("heartbeat with userID [%s] and owner [%s] does not exist", userID, owner)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg))
+ }
+ if err != nil {
+ msg := fmt.Sprintf("cannot load heartbeat with userID [%s] and owner [%s]", userID, owner)
+ return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return &heartbeat, nil
+}
+
+func (repository *mongoHeartbeatRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error {
+ ctx, span := repository.tracer.Start(ctx)
+ defer span.End()
+
+ ctx, cancel := context.WithTimeout(ctx, dbOperationDuration)
+ defer cancel()
+
+ _, err := repository.collection.DeleteMany(ctx, bson.D{{"user_id", string(userID)}})
+ if err != nil {
+ msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.Heartbeat{}, userID)
+ return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
diff --git a/api/pkg/repositories/mongodb.go b/api/pkg/repositories/mongodb.go
new file mode 100644
index 00000000..f76f97bc
--- /dev/null
+++ b/api/pkg/repositories/mongodb.go
@@ -0,0 +1,127 @@
+package repositories
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "reflect"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/palantir/stacktrace"
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+ "go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo"
+)
+
+const (
+ collectionHeartbeats = "heartbeats"
+ collectionHeartbeatMonitors = "heartbeat_monitors"
+)
+
+// uuidEncodeValue encodes uuid.UUID as a BSON string
+func uuidEncodeValue(_ bson.EncodeContext, vw bson.ValueWriter, val reflect.Value) error {
+ u := val.Interface().(uuid.UUID)
+ return vw.WriteString(u.String())
+}
+
+// uuidDecodeValue decodes a BSON string into uuid.UUID
+func uuidDecodeValue(_ bson.DecodeContext, vr bson.ValueReader, val reflect.Value) error {
+ str, err := vr.ReadString()
+ if err != nil {
+ return err
+ }
+ parsed, err := uuid.Parse(str)
+ if err != nil {
+ return err
+ }
+ val.Set(reflect.ValueOf(parsed))
+ return nil
+}
+
+// newMongoRegistry creates a BSON registry that encodes uuid.UUID as strings
+func newMongoRegistry() *bson.Registry {
+ rb := bson.NewRegistry()
+ rb.RegisterTypeEncoder(reflect.TypeOf(uuid.UUID{}), bson.ValueEncoderFunc(uuidEncodeValue))
+ rb.RegisterTypeDecoder(reflect.TypeOf(uuid.UUID{}), bson.ValueDecoderFunc(uuidDecodeValue))
+ return rb
+}
+
+// NewMongoDB creates a new *mongo.Database connection to MongoDB Atlas and ensures indexes.
+// The database name is derived from the appName query parameter in the URI.
+func NewMongoDB(uri string) (*mongo.Database, error) {
+ dbName, err := parseMongoDBName(uri)
+ if err != nil {
+ return nil, stacktrace.Propagate(err, "cannot parse database name from MongoDB URI")
+ }
+
+ pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer pingCancel()
+
+ serverAPI := options.ServerAPI(options.ServerAPIVersion1)
+ registry := newMongoRegistry()
+ opts := options.Client().
+ ApplyURI(uri).
+ SetServerAPIOptions(serverAPI).
+ SetRegistry(registry).
+ SetMonitor(otelmongo.NewMonitor())
+
+ client, err := mongo.Connect(opts)
+ if err != nil {
+ return nil, stacktrace.Propagate(err, "cannot connect to MongoDB Atlas")
+ }
+
+ if err = client.Ping(pingCtx, nil); err != nil {
+ return nil, stacktrace.Propagate(err, "cannot ping MongoDB Atlas")
+ }
+
+ db := client.Database(dbName)
+
+ indexCtx, indexCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer indexCancel()
+
+ if err = createMongoIndexes(indexCtx, db); err != nil {
+ return nil, stacktrace.Propagate(err, "cannot create MongoDB indexes")
+ }
+
+ return db, nil
+}
+
+// parseMongoDBName extracts the appName query parameter from the MongoDB URI to use as the database name
+func parseMongoDBName(uri string) (string, error) {
+ parsed, err := url.Parse(uri)
+ if err != nil {
+ return "", stacktrace.Propagate(err, fmt.Sprintf("cannot parse MongoDB URI [%s]", uri))
+ }
+
+ appName := parsed.Query().Get("appName")
+ if appName == "" {
+ return "", stacktrace.NewError("MongoDB URI is missing the 'appName' query parameter which is used as the database name")
+ }
+
+ return appName, nil
+}
+
+func createMongoIndexes(ctx context.Context, db *mongo.Database) error {
+ // Heartbeats indexes
+ heartbeatsCol := db.Collection(collectionHeartbeats)
+
+ _, err := heartbeatsCol.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{"user_id", 1}, {"owner", 1}, {"timestamp", -1}}},
+ })
+ if err != nil {
+ return stacktrace.Propagate(err, "cannot create indexes on heartbeats collection")
+ }
+
+ // Heartbeat monitors indexes
+ monitorsCol := db.Collection(collectionHeartbeatMonitors)
+ _, err = monitorsCol.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{"user_id", 1}, {"owner", 1}}},
+ })
+ if err != nil {
+ return stacktrace.Propagate(err, "cannot create indexes on heartbeat_monitors collection")
+ }
+
+ return nil
+}
diff --git a/api/pkg/repositories/phone_api_key_repository.go b/api/pkg/repositories/phone_api_key_repository.go
index 8894e4ac..ceecda85 100644
--- a/api/pkg/repositories/phone_api_key_repository.go
+++ b/api/pkg/repositories/phone_api_key_repository.go
@@ -31,6 +31,9 @@ type PhoneAPIKeyRepository interface {
// RemovePhone removes an entities.Phone to an entities.PhoneAPIKey
RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error
+ // CountByUser returns the number of phone API keys owned by a user
+ CountByUser(ctx context.Context, userID entities.UserID) (int, error)
+
// DeleteAllForUser deletes all entities.PhoneAPIKey for a user
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
diff --git a/api/pkg/repositories/phone_notification_repository.go b/api/pkg/repositories/phone_notification_repository.go
index 87f78490..2abc67f9 100644
--- a/api/pkg/repositories/phone_notification_repository.go
+++ b/api/pkg/repositories/phone_notification_repository.go
@@ -11,11 +11,18 @@ import (
// PhoneNotificationRepository loads and persists an entities.PhoneNotification
type PhoneNotificationRepository interface {
// Schedule a new entities.PhoneNotification
- Schedule(ctx context.Context, messagesPerMinute uint, notification *entities.PhoneNotification) error
+ Schedule(ctx context.Context, messagesPerMinute uint, schedule *entities.MessageSendSchedule, notification *entities.PhoneNotification) error
+
+ // ScheduleExact stores a phone notification with a fixed ScheduledAt time,
+ // bypassing rate-limit and schedule window logic.
+ ScheduleExact(ctx context.Context, notification *entities.PhoneNotification) error
// UpdateStatus of a notification
UpdateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) error
// DeleteAllForUser deletes all entities.PhoneNotification for a user
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
+
+ // DeleteByMessageID deletes entities.PhoneNotification for a message and user
+ DeleteByMessageID(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error
}
diff --git a/api/pkg/repositories/phone_repository.go b/api/pkg/repositories/phone_repository.go
index c9e82b98..2c184963 100644
--- a/api/pkg/repositories/phone_repository.go
+++ b/api/pkg/repositories/phone_repository.go
@@ -25,6 +25,9 @@ type PhoneRepository interface {
// Delete an entities.Phone
Delete(ctx context.Context, userID entities.UserID, phoneID uuid.UUID) error
+ // NullifyScheduleID sets MessageSendScheduleID to NULL for all phones referencing the given schedule
+ NullifyScheduleID(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error
+
// DeleteAllForUser deletes all entities.Phone for a user
DeleteAllForUser(ctx context.Context, userID entities.UserID) error
}
diff --git a/api/pkg/requests/bulk_message_request.go b/api/pkg/requests/bulk_message_request.go
index ffb3f35c..345e24aa 100644
--- a/api/pkg/requests/bulk_message_request.go
+++ b/api/pkg/requests/bulk_message_request.go
@@ -1,23 +1,57 @@
package requests
import (
- "fmt"
"strings"
"time"
"github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/services"
- "github.com/google/uuid"
"github.com/nyaruka/phonenumbers"
)
// BulkMessage represents a single message in a bulk SMS request
type BulkMessage struct {
request
- FromPhoneNumber string `csv:"FromPhoneNumber"`
- ToPhoneNumber string `csv:"ToPhoneNumber"`
- Content string `csv:"Content"`
- SendTime *time.Time `csv:"SendTime(optional)"`
+ FileType string `json:"type"`
+ FromPhoneNumber string `csv:"FromPhoneNumber"`
+ ToPhoneNumber string `csv:"ToPhoneNumber"`
+ Content string `csv:"Content"`
+ SendTime string `csv:"SendTime(optional)"`
+ AttachmentURLs string `csv:"AttachmentURLs(optional)" validate:"optional"` // Comma separated list of URLs
+}
+
+// GetSendTime parses the raw SendTime string into a *time.Time.
+// For timezone-naive formats, the time is interpreted in the given location.
+// For RFC3339 (which includes an offset), the embedded offset is used.
+func (input *BulkMessage) GetSendTime(location *time.Location) *time.Time {
+ raw := strings.TrimSpace(input.SendTime)
+ if raw == "" {
+ return nil
+ }
+
+ if location == nil {
+ location = time.UTC
+ }
+
+ // RFC3339 already contains timezone offset, parse without location
+ if t, err := time.Parse(time.RFC3339, raw); err == nil {
+ utc := t.UTC()
+ return &utc
+ }
+
+ // Naive formats: interpret in the user's location
+ naiveFormats := []string{
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ }
+
+ for _, format := range naiveFormats {
+ if t, err := time.ParseInLocation(format, raw, location); err == nil {
+ utc := t.UTC()
+ return &utc
+ }
+ }
+ return nil
}
// Sanitize sets defaults to BulkMessage
@@ -25,20 +59,31 @@ func (input *BulkMessage) Sanitize() *BulkMessage {
input.ToPhoneNumber = input.sanitizeAddress(input.ToPhoneNumber)
input.Content = strings.TrimSpace(input.Content)
input.FromPhoneNumber = input.sanitizeAddress(input.FromPhoneNumber)
+
+ var attachments []string
+ for _, attachment := range strings.Split(input.AttachmentURLs, ",") {
+ if strings.TrimSpace(attachment) != "" {
+ attachments = append(attachments, strings.TrimSpace(attachment))
+ }
+ }
+ input.AttachmentURLs = strings.Join(attachments, ",")
return input
}
// ToMessageSendParams converts BulkMessage to services.MessageSendParams
-func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID uuid.UUID, source string) services.MessageSendParams {
+func (input *BulkMessage) ToMessageSendParams(userID entities.UserID, requestID string, source string, index int, location *time.Location) services.MessageSendParams {
from, _ := phonenumbers.Parse(input.FromPhoneNumber, phonenumbers.UNKNOWN_REGION)
+
return services.MessageSendParams{
Source: source,
Owner: from,
- RequestID: input.sanitizeStringPointer(fmt.Sprintf("bulk-%s", requestID.String())),
+ RequestID: input.sanitizeStringPointer(requestID),
UserID: userID,
- SendAt: input.SendTime,
+ SendAt: input.GetSendTime(location),
RequestReceivedAt: time.Now().UTC(),
Contact: input.sanitizeAddress(input.ToPhoneNumber),
Content: input.Content,
+ Attachments: input.removeEmptyStrings(strings.Split(input.AttachmentURLs, ",")),
+ Index: index,
}
}
diff --git a/api/pkg/requests/message_bulk_send_request.go b/api/pkg/requests/message_bulk_send_request.go
index a21570bb..8c7d8025 100644
--- a/api/pkg/requests/message_bulk_send_request.go
+++ b/api/pkg/requests/message_bulk_send_request.go
@@ -1,6 +1,7 @@
package requests
import (
+ "strings"
"time"
"github.com/NdoleStudio/httpsms/pkg/entities"
@@ -17,8 +18,11 @@ type MessageBulkSend struct {
To []string `json:"to" example:"+18005550100,+18005550100"`
Content string `json:"content" example:"This is a sample text message"`
+ // Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS
+ Attachments []string `json:"attachments" validate:"optional"`
+
// Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
- Encrypted bool `json:"encrypted" example:"false"`
+ Encrypted bool `json:"encrypted" example:"false" validate:"optional"`
// RequestID is an optional parameter used to track a request from the client's perspective
RequestID string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
@@ -30,6 +34,15 @@ func (input *MessageBulkSend) Sanitize() MessageBulkSend {
for _, address := range input.To {
to = append(to, input.sanitizeAddress(address))
}
+
+ var attachments []string
+ for _, attachment := range input.Attachments {
+ if strings.TrimSpace(attachment) != "" {
+ attachments = append(attachments, strings.TrimSpace(attachment))
+ }
+ }
+
+ input.Attachments = attachments
input.To = to
input.From = input.sanitizeAddress(input.From)
return *input
@@ -41,7 +54,6 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source
var result []services.MessageSendParams
for index, to := range input.To {
- sendAt := time.Now().UTC().Add(time.Duration(index) * time.Second)
result = append(result, services.MessageSendParams{
Source: source,
Owner: from,
@@ -50,8 +62,9 @@ func (input *MessageBulkSend) ToMessageSendParams(userID entities.UserID, source
UserID: userID,
RequestReceivedAt: time.Now().UTC(),
Contact: to,
- SendAt: &sendAt,
Content: input.Content,
+ Attachments: input.Attachments,
+ Index: index,
})
}
diff --git a/api/pkg/requests/message_receive_request.go b/api/pkg/requests/message_receive_request.go
index f592761c..b89cddfa 100644
--- a/api/pkg/requests/message_receive_request.go
+++ b/api/pkg/requests/message_receive_request.go
@@ -11,6 +11,16 @@ import (
"github.com/NdoleStudio/httpsms/pkg/services"
)
+// MessageAttachment represents a single MMS attachment in a receive request
+type MessageAttachment struct {
+ // Name is the original filename of the attachment
+ Name string `json:"name" example:"photo.jpg"`
+ // ContentType is the MIME type of the attachment
+ ContentType string `json:"content_type" example:"image/jpeg"`
+ // Content is the base64-encoded attachment data
+ Content string `json:"content" example:"base64data..."`
+}
+
// MessageReceive is the payload for sending and SMS message
type MessageReceive struct {
request
@@ -23,6 +33,8 @@ type MessageReceive struct {
SIM entities.SIM `json:"sim" example:"SIM1"`
// Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible
Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"`
+ // Attachments is the list of MMS attachments received with the message
+ Attachments []MessageAttachment `json:"attachments" validate:"optional"`
}
// Sanitize sets defaults to MessageReceive
@@ -38,14 +50,25 @@ func (input *MessageReceive) Sanitize() MessageReceive {
// ToMessageReceiveParams converts MessageReceive to services.MessageReceiveParams
func (input *MessageReceive) ToMessageReceiveParams(userID entities.UserID, source string) *services.MessageReceiveParams {
phone, _ := phonenumbers.Parse(input.To, phonenumbers.UNKNOWN_REGION)
+
+ attachments := make([]services.ServiceAttachment, len(input.Attachments))
+ for i, a := range input.Attachments {
+ attachments[i] = services.ServiceAttachment{
+ Name: a.Name,
+ ContentType: a.ContentType,
+ Content: a.Content,
+ }
+ }
+
return &services.MessageReceiveParams{
- Source: source,
- Contact: input.From,
- UserID: userID,
- Timestamp: input.Timestamp,
- Encrypted: input.Encrypted,
- Owner: *phone,
- Content: input.Content,
- SIM: input.SIM,
+ Source: source,
+ Contact: input.From,
+ UserID: userID,
+ Timestamp: input.Timestamp,
+ Encrypted: input.Encrypted,
+ Owner: *phone,
+ Content: input.Content,
+ SIM: input.SIM,
+ Attachments: attachments,
}
}
diff --git a/api/pkg/requests/message_send_request.go b/api/pkg/requests/message_send_request.go
index 3301691d..727cc12e 100644
--- a/api/pkg/requests/message_send_request.go
+++ b/api/pkg/requests/message_send_request.go
@@ -18,6 +18,9 @@ type MessageSend struct {
To string `json:"to" example:"+18005550100"`
Content string `json:"content" example:"This is a sample text message"`
+ // Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS
+ Attachments []string `json:"attachments" validate:"optional" example:"https://example.com/image.jpg,https://example.com/video.mp4"`
+
// Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
Encrypted bool `json:"encrypted" example:"false" validate:"optional"`
// RequestID is an optional parameter used to track a request from the client's perspective
@@ -31,6 +34,13 @@ func (input *MessageSend) Sanitize() MessageSend {
input.To = input.sanitizeAddress(input.To)
input.RequestID = strings.TrimSpace(input.RequestID)
input.From = input.sanitizeAddress(input.From)
+ var attachments []string
+ for _, attachment := range input.Attachments {
+ if strings.TrimSpace(attachment) != "" {
+ attachments = append(attachments, strings.TrimSpace(attachment))
+ }
+ }
+ input.Attachments = attachments
return *input
}
@@ -47,5 +57,6 @@ func (input *MessageSend) ToMessageSendParams(userID entities.UserID, source str
RequestReceivedAt: time.Now().UTC(),
Contact: input.sanitizeAddress(input.To),
Content: input.Content,
+ Attachments: input.Attachments,
}
}
diff --git a/api/pkg/requests/message_send_schedule_store_request.go b/api/pkg/requests/message_send_schedule_store_request.go
new file mode 100644
index 00000000..bd69fdc9
--- /dev/null
+++ b/api/pkg/requests/message_send_schedule_store_request.go
@@ -0,0 +1,51 @@
+package requests
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+)
+
+// MessageSendScheduleWindow represents a single request window for a message send schedule.
+type MessageSendScheduleWindow struct {
+ DayOfWeek int `json:"day_of_week"`
+ StartMinute int `json:"start_minute"`
+ EndMinute int `json:"end_minute"`
+}
+
+// MessageSendScheduleStore contains the payload used to create or update a message send schedule.
+type MessageSendScheduleStore struct {
+ request
+ Name string `json:"name"`
+ Timezone string `json:"timezone"`
+ Windows []MessageSendScheduleWindow `json:"windows"`
+}
+
+// Sanitize trims and sorts the message send schedule payload before validation.
+func (input *MessageSendScheduleStore) Sanitize() MessageSendScheduleStore {
+ input.Name = strings.TrimSpace(input.Name)
+ input.Timezone = strings.TrimSpace(input.Timezone)
+ windows := make([]MessageSendScheduleWindow, 0, len(input.Windows))
+ for _, item := range input.Windows {
+ windows = append(windows, MessageSendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute})
+ }
+ sort.SliceStable(windows, func(i, j int) bool {
+ if windows[i].DayOfWeek == windows[j].DayOfWeek {
+ return windows[i].StartMinute < windows[j].StartMinute
+ }
+ return windows[i].DayOfWeek < windows[j].DayOfWeek
+ })
+ input.Windows = windows
+ return *input
+}
+
+// ToParams converts the request payload into message send schedule service params.
+func (input *MessageSendScheduleStore) ToParams(user entities.AuthContext) *services.MessageSendScheduleUpsertParams {
+ windows := make([]entities.MessageSendScheduleWindow, 0, len(input.Windows))
+ for _, item := range input.Windows {
+ windows = append(windows, entities.MessageSendScheduleWindow{DayOfWeek: item.DayOfWeek, StartMinute: item.StartMinute, EndMinute: item.EndMinute})
+ }
+ return &services.MessageSendScheduleUpsertParams{UserID: user.ID, Name: input.Name, Timezone: input.Timezone, Windows: windows}
+}
diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go
index f920fad4..462d6428 100644
--- a/api/pkg/requests/phone_update_request.go
+++ b/api/pkg/requests/phone_update_request.go
@@ -1,9 +1,12 @@
package requests
import (
+ "encoding/json"
"strings"
"time"
+ "github.com/google/uuid"
+
"github.com/nyaruka/phonenumbers"
"github.com/NdoleStudio/httpsms/pkg/entities"
@@ -28,6 +31,8 @@ type PhoneUpsert struct {
// SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot
SIM string `json:"sim" example:"SIM1"`
+
+ MessageSendScheduleID string `json:"message_send_schedule_id,omitempty" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"`
}
// Sanitize sets defaults to MessageOutstanding
@@ -41,34 +46,42 @@ func (input *PhoneUpsert) Sanitize() PhoneUpsert {
return *input
}
-// ToUpsertParams converts PhoneUpsert to services.PhoneUpsertParams
-func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source string) *services.PhoneUpsertParams {
+// ToUpsertParams converts PhoneUpsert to services.PhoneUpsertParams.
+// The body parameter is the raw JSON request body used to detect which fields were explicitly sent.
+func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source string, body []byte) *services.PhoneUpsertParams {
phone, _ := phonenumbers.Parse(input.PhoneNumber, phonenumbers.UNKNOWN_REGION)
- // ignore value if it's default
+ fields := make(map[string]json.RawMessage)
+ _ = json.Unmarshal(body, &fields)
+
var messagesPerMinute *uint
- if input.MessagesPerMinute != 0 {
+ if _, exists := fields["messages_per_minute"]; exists {
messagesPerMinute = &input.MessagesPerMinute
}
- // ignore default
var fcmToken *string
- if input.FcmToken != "" {
+ if _, exists := fields["fcm_token"]; exists {
fcmToken = &input.FcmToken
}
- // ignore default
var timeout *time.Duration
- if input.MessageExpirationSeconds != 0 {
+ if _, exists := fields["message_expiration_seconds"]; exists {
duration := time.Duration(input.MessageExpirationSeconds) * time.Second
timeout = &duration
}
var maxSendAttempts *uint
- if input.MaxSendAttempts != 0 {
+ if _, exists := fields["max_send_attempts"]; exists {
maxSendAttempts = &input.MaxSendAttempts
}
+ var scheduleID *uuid.UUID
+ if _, exists := fields["message_send_schedule_id"]; exists {
+ if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil {
+ scheduleID = &parsed
+ }
+ }
+
return &services.PhoneUpsertParams{
Source: source,
PhoneNumber: phone,
@@ -79,5 +92,6 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin
FcmToken: fcmToken,
UserID: user.ID,
SIM: entities.SIM(input.SIM),
+ MessageSendScheduleID: scheduleID,
}
}
diff --git a/api/pkg/requests/request.go b/api/pkg/requests/request.go
index 851137d1..1db27861 100644
--- a/api/pkg/requests/request.go
+++ b/api/pkg/requests/request.go
@@ -108,6 +108,18 @@ func (input *request) removeStringDuplicates(values []string) []string {
return result
}
+func (input *request) removeEmptyStrings(values []string) []string {
+ var result []string
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ result = append(result, value)
+ }
+ }
+
+ return result
+}
+
func (input *request) sanitizeMessageID(value string) string {
id := strings.Builder{}
for _, char := range value {
diff --git a/api/pkg/requests/user_payment_invoice_request.go b/api/pkg/requests/user_payment_invoice_request.go
new file mode 100644
index 00000000..4d196f4a
--- /dev/null
+++ b/api/pkg/requests/user_payment_invoice_request.go
@@ -0,0 +1,46 @@
+package requests
+
+import (
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+)
+
+// UserPaymentInvoice is the payload for generating a subscription payment invoice
+type UserPaymentInvoice struct {
+ request
+ Name string `json:"name" example:"Acme Corp"`
+ Address string `json:"address" example:"221B Baker Street, London"`
+ City string `json:"city" example:"Los Angeles"`
+ State string `json:"state" example:"CA"`
+ Country string `json:"country" example:"US"`
+ ZipCode string `json:"zip_code" example:"9800"`
+ Notes string `json:"notes" example:"Thank you for your business!"`
+ SubscriptionInvoiceID string `json:"subscriptionInvoiceID" swaggerignore:"true"` // used internally for validation
+}
+
+// Sanitize sets defaults to MessageReceive
+func (input *UserPaymentInvoice) Sanitize() UserPaymentInvoice {
+ input.Name = input.sanitizeAddress(input.Name)
+ input.Address = input.sanitizeAddress(input.Address)
+ input.City = input.sanitizeAddress(input.City)
+ input.State = input.sanitizeAddress(input.State)
+ input.Country = input.sanitizeAddress(input.Country)
+ input.ZipCode = input.sanitizeAddress(input.ZipCode)
+ input.Notes = input.sanitizeAddress(input.Notes)
+ return *input
+}
+
+// UserInvoiceGenerateParams converts UserPaymentInvoice to services.UserInvoiceGenerateParams
+func (input *UserPaymentInvoice) UserInvoiceGenerateParams(userID entities.UserID) *services.UserInvoiceGenerateParams {
+ return &services.UserInvoiceGenerateParams{
+ UserID: userID,
+ SubscriptionInvoiceID: input.SubscriptionInvoiceID,
+ Name: input.Name,
+ Address: input.Address,
+ City: input.City,
+ State: input.State,
+ Country: input.Country,
+ Notes: input.Notes,
+ ZipCode: input.ZipCode,
+ }
+}
diff --git a/api/pkg/responses/billing_responses.go b/api/pkg/responses/billing_responses.go
index bb51d6ab..0ce46415 100644
--- a/api/pkg/responses/billing_responses.go
+++ b/api/pkg/responses/billing_responses.go
@@ -1,6 +1,8 @@
package responses
-import "github.com/NdoleStudio/httpsms/pkg/entities"
+import (
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+)
// BillingUsagesResponse is the payload containing []entities.BillingUsage
type BillingUsagesResponse struct {
diff --git a/api/pkg/responses/bulk_message_responses.go b/api/pkg/responses/bulk_message_responses.go
new file mode 100644
index 00000000..eda242a6
--- /dev/null
+++ b/api/pkg/responses/bulk_message_responses.go
@@ -0,0 +1,9 @@
+package responses
+
+import "github.com/NdoleStudio/httpsms/pkg/entities"
+
+// BulkMessagesResponse is the payload containing []*entities.BulkMessage
+type BulkMessagesResponse struct {
+ response
+ Data []*entities.BulkMessage `json:"data"`
+}
diff --git a/api/pkg/responses/message_send_schedule_responses.go b/api/pkg/responses/message_send_schedule_responses.go
new file mode 100644
index 00000000..630dba13
--- /dev/null
+++ b/api/pkg/responses/message_send_schedule_responses.go
@@ -0,0 +1,15 @@
+package responses
+
+import "github.com/NdoleStudio/httpsms/pkg/entities"
+
+// MessageSendSchedulesResponse represents a collection of message send schedules.
+type MessageSendSchedulesResponse struct {
+ response
+ Data []entities.MessageSendSchedule `json:"data"`
+}
+
+// MessageSendScheduleResponse represents a single message send schedule.
+type MessageSendScheduleResponse struct {
+ response
+ Data entities.MessageSendSchedule `json:"data"`
+}
diff --git a/api/pkg/responses/response.go b/api/pkg/responses/response.go
index c61c919e..e23fea0f 100644
--- a/api/pkg/responses/response.go
+++ b/api/pkg/responses/response.go
@@ -38,6 +38,12 @@ type Unauthorized struct {
Data string `json:"data" example:"Make sure your API key is set in the [X-API-Key] header in the request"`
}
+// PaymentRequired is the response with status code is 402
+type PaymentRequired struct {
+ Status string `json:"status" example:"error"`
+ Message string `json:"message" example:"You have reached the maximum number of allowed resources. Please upgrade your plan."`
+}
+
// NoContent is the response when status code is 204
type NoContent struct {
Status string `json:"status" example:"success"`
diff --git a/api/pkg/responses/user_responses.go b/api/pkg/responses/user_responses.go
index f2ee6c37..31f95341 100644
--- a/api/pkg/responses/user_responses.go
+++ b/api/pkg/responses/user_responses.go
@@ -1,9 +1,51 @@
package responses
-import "github.com/NdoleStudio/httpsms/pkg/entities"
+import (
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+)
// UserResponse is the payload containing entities.User
type UserResponse struct {
response
Data entities.User `json:"data"`
}
+
+// UserSubscriptionPaymentsResponse is the payload containing lemonsqueezy.SubscriptionInvoicesAPIResponse
+type UserSubscriptionPaymentsResponse struct {
+ response
+ Data []struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Attributes struct {
+ BillingReason string `json:"billing_reason"`
+ CardBrand string `json:"card_brand"`
+ CardLastFour string `json:"card_last_four"`
+ Currency string `json:"currency"`
+ CurrencyRate string `json:"currency_rate"`
+ Status string `json:"status"`
+ StatusFormatted string `json:"status_formatted"`
+ Refunded bool `json:"refunded"`
+ RefundedAt any `json:"refunded_at"`
+ Subtotal int `json:"subtotal"`
+ DiscountTotal int `json:"discount_total"`
+ Tax int `json:"tax"`
+ TaxInclusive bool `json:"tax_inclusive"`
+ Total int `json:"total"`
+ RefundedAmount int `json:"refunded_amount"`
+ SubtotalUsd int `json:"subtotal_usd"`
+ DiscountTotalUsd int `json:"discount_total_usd"`
+ TaxUsd int `json:"tax_usd"`
+ TotalUsd int `json:"total_usd"`
+ RefundedAmountUsd int `json:"refunded_amount_usd"`
+ SubtotalFormatted string `json:"subtotal_formatted"`
+ DiscountTotalFormatted string `json:"discount_total_formatted"`
+ TaxFormatted string `json:"tax_formatted"`
+ TotalFormatted string `json:"total_formatted"`
+ RefundedAmountFormatted string `json:"refunded_amount_formatted"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
diff --git a/api/pkg/services/discord_service.go b/api/pkg/services/discord_service.go
index 059231b9..8c608e9f 100644
--- a/api/pkg/services/discord_service.go
+++ b/api/pkg/services/discord_service.go
@@ -169,6 +169,12 @@ func (service *DiscordService) createSlashCommand(ctx context.Context, serverID
Type: 3,
Required: true,
},
+ {
+ Name: "attachment_urls",
+ Description: "Comma-separated list of media URLs to attach",
+ Type: 3,
+ Required: false,
+ },
},
})
if err != nil {
diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go
new file mode 100644
index 00000000..85060bb4
--- /dev/null
+++ b/api/pkg/services/emulator_fcm_client.go
@@ -0,0 +1,100 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "firebase.google.com/go/messaging"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
+)
+
+// EmulatorFCMClient sends FCM messages to the phone emulator via HTTP.
+type EmulatorFCMClient struct {
+ httpClient *http.Client
+ endpoint string
+ logger telemetry.Logger
+}
+
+// NewEmulatorFCMClient creates a new EmulatorFCMClient.
+func NewEmulatorFCMClient(httpClient *http.Client, endpoint string, logger telemetry.Logger) *EmulatorFCMClient {
+ return &EmulatorFCMClient{
+ httpClient: httpClient,
+ endpoint: endpoint,
+ logger: logger,
+ }
+}
+
+// emulatorFCMRequest is the payload sent to the emulator's FCM endpoint.
+type emulatorFCMRequest struct {
+ Message *emulatorFCMMessage `json:"message"`
+}
+
+type emulatorFCMMessage struct {
+ Token string `json:"token"`
+ Data map[string]string `json:"data,omitempty"`
+ Android *emulatorAndroid `json:"android,omitempty"`
+}
+
+type emulatorAndroid struct {
+ Priority string `json:"priority,omitempty"`
+}
+
+// emulatorFCMResponse is the response from the emulator.
+type emulatorFCMResponse struct {
+ Name string `json:"name"`
+}
+
+// Send sends a message to the emulator's FCM endpoint.
+func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
+ payload := &emulatorFCMRequest{
+ Message: &emulatorFCMMessage{
+ Token: message.Token,
+ Data: message.Data,
+ },
+ }
+ if message.Android != nil {
+ payload.Message.Android = &emulatorAndroid{
+ Priority: message.Android.Priority,
+ }
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator")
+ }
+
+ url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM")
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to emulator at [%s]", url))
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", stacktrace.Propagate(err, "cannot read emulator FCM response body")
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result emulatorFCMResponse
+ if err = json.Unmarshal(respBody, &result); err != nil {
+ return "", stacktrace.Propagate(err, "cannot decode emulator FCM response")
+ }
+
+ c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name))
+ return result.Name, nil
+}
diff --git a/api/pkg/services/entitlement_service.go b/api/pkg/services/entitlement_service.go
new file mode 100644
index 00000000..1010c89c
--- /dev/null
+++ b/api/pkg/services/entitlement_service.go
@@ -0,0 +1,149 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "unicode"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ pluralize "github.com/gertd/go-pluralize"
+ "github.com/palantir/stacktrace"
+)
+
+// entityLimits maps entity name → subscription plan → max count.
+// A limit of 0 means unlimited. If a plan is not listed, it defaults to unlimited (0).
+var entityLimits = map[string]map[entities.SubscriptionName]int{
+ entities.EntityNameMessageSendSchedule: {
+ entities.SubscriptionNameFree: 1,
+ },
+ entities.EntityNamePhoneAPIKey: {
+ entities.SubscriptionNameFree: 1,
+ },
+}
+
+// EntitlementCheckResult holds the outcome of an entitlement check.
+type EntitlementCheckResult struct {
+ Allowed bool
+ Message string
+}
+
+// EntitlementService checks whether a user can create more of a given entity
+// based on their subscription plan.
+type EntitlementService struct {
+ service
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ enabled bool
+ userRepository repositories.UserRepository
+}
+
+// NewEntitlementService creates a new EntitlementService.
+// The enabled flag should come from the ENTITLEMENT_ENABLED environment variable.
+func NewEntitlementService(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ enabled bool,
+ userRepository repositories.UserRepository,
+) *EntitlementService {
+ return &EntitlementService{
+ logger: logger.WithService(fmt.Sprintf("%T", &EntitlementService{})),
+ tracer: tracer,
+ enabled: enabled,
+ userRepository: userRepository,
+ }
+}
+
+// Check verifies if the user can create another instance of the given entity.
+func (service *EntitlementService) Check(
+ ctx context.Context,
+ userID entities.UserID,
+ entityName string,
+ countFunc func() (int, error),
+) (*EntitlementCheckResult, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ if !service.enabled {
+ return &EntitlementCheckResult{Allowed: true}, nil
+ }
+
+ limits, exists := entityLimits[entityName]
+ if !exists {
+ return &EntitlementCheckResult{Allowed: true}, nil
+ }
+
+ user, err := service.userRepository.Load(ctx, userID)
+ if err != nil {
+ return nil, service.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s] for entitlement check", userID)),
+ )
+ }
+
+ limit, hasLimit := limits[user.SubscriptionName]
+ if !hasLimit || limit == 0 {
+ return &EntitlementCheckResult{Allowed: true}, nil
+ }
+
+ currentCount, err := countFunc()
+ if err != nil {
+ return nil, service.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(err, fmt.Sprintf("cannot count entities [%s] for user [%s]", entityName, userID)),
+ )
+ }
+
+ if currentCount >= limit {
+ return &EntitlementCheckResult{
+ Allowed: false,
+ Message: fmt.Sprintf(
+ "Upgrade to a paid plan to create more than [%d] %s. Visit https://httpsms.com/pricing for details.",
+ limit,
+ formatEntityName(entityName, true),
+ ),
+ }, nil
+ }
+
+ return &EntitlementCheckResult{Allowed: true}, nil
+}
+
+// formatEntityName converts a PascalCase entity name to lowercase words and optionally pluralizes it.
+// Consecutive uppercase letters (acronyms like API) are kept together as a single word.
+// e.g. "MessageSendSchedule" → "message send schedules", "PhoneAPIKey" → "phone API keys"
+func formatEntityName(name string, plural bool) string {
+ var words []string
+ runes := []rune(name)
+ start := 0
+ for i := 1; i < len(runes); i++ {
+ if unicode.IsUpper(runes[i]) {
+ if !unicode.IsUpper(runes[i-1]) {
+ // transition from lowercase to uppercase: split before i
+ words = append(words, string(runes[start:i]))
+ start = i
+ } else if i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
+ // transition from uppercase run to a new word (e.g., "API" followed by "Key")
+ words = append(words, string(runes[start:i]))
+ start = i
+ }
+ }
+ }
+ words = append(words, string(runes[start:]))
+
+ for i, word := range words {
+ if word == strings.ToUpper(word) && len(word) > 1 {
+ // keep acronyms uppercase (e.g., "API")
+ continue
+ }
+ words[i] = strings.ToLower(word)
+ }
+
+ if plural && len(words) > 0 {
+ client := pluralize.NewClient()
+ words[len(words)-1] = client.Plural(words[len(words)-1])
+ }
+
+ return strings.Join(words, " ")
+}
diff --git a/api/pkg/services/event_dispatcher_service.go b/api/pkg/services/event_dispatcher_service.go
index dfb6dae6..4b9445de 100644
--- a/api/pkg/services/event_dispatcher_service.go
+++ b/api/pkg/services/event_dispatcher_service.go
@@ -11,6 +11,7 @@ import (
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.18.0"
+ "go.opentelemetry.io/otel/trace"
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
@@ -119,12 +120,12 @@ func (dispatcher *EventDispatcher) Subscribe(eventType string, listener events.E
// Publish an event to subscribers
func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevents.Event) {
- ctx, span := dispatcher.tracer.Start(ctx)
+ ctx, span, ctxLogger := dispatcher.tracer.StartWithLogger(ctx, dispatcher.logger)
defer span.End()
- start := time.Now()
+ dispatcher.addCloudEventAttributes(span, event)
- ctxLogger := dispatcher.tracer.CtxLogger(dispatcher.logger, span)
+ start := time.Now()
subscribers, ok := dispatcher.listeners[event.Type()]
if !ok {
@@ -148,7 +149,7 @@ func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevent
dispatcher.meter.Record(
ctx,
- float64(time.Since(start).Microseconds())/1000,
+ float64(time.Since(start).Milliseconds()),
metric.WithAttributes(
semconv.CloudeventsEventType(event.Type()),
semconv.CloudeventsEventSpecVersion(event.SpecVersion()),
@@ -156,6 +157,15 @@ func (dispatcher *EventDispatcher) Publish(ctx context.Context, event cloudevent
)
}
+func (dispatcher *EventDispatcher) addCloudEventAttributes(span trace.Span, event cloudevents.Event) {
+ span.SetAttributes(
+ semconv.CloudeventsEventType(event.Type()),
+ semconv.CloudeventsEventID(event.ID()),
+ semconv.CloudeventsEventSource(event.Source()),
+ semconv.CloudeventsEventSpecVersion(event.SpecVersion()),
+ )
+}
+
func (dispatcher *EventDispatcher) createCloudTask(event cloudevents.Event) (*PushQueueTask, error) {
eventContent, err := json.Marshal(event)
if err != nil {
diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go
new file mode 100644
index 00000000..4e56f316
--- /dev/null
+++ b/api/pkg/services/fcm_client.go
@@ -0,0 +1,28 @@
+package services
+
+import (
+ "context"
+
+ "firebase.google.com/go/messaging"
+)
+
+// FCMClient is the interface for sending Firebase Cloud Messaging notifications.
+type FCMClient interface {
+ // Send sends a message via FCM and returns the message name on success.
+ Send(ctx context.Context, message *messaging.Message) (string, error)
+}
+
+// FirebaseFCMClient wraps the real Firebase messaging.Client.
+type FirebaseFCMClient struct {
+ client *messaging.Client
+}
+
+// NewFirebaseFCMClient creates a new FirebaseFCMClient.
+func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient {
+ return &FirebaseFCMClient{client: client}
+}
+
+// Send sends a message via the real Firebase SDK.
+func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
+ return c.client.Send(ctx, message)
+}
diff --git a/api/pkg/services/google_cloud_push_queue_service.go b/api/pkg/services/google_cloud_push_queue_service.go
index 194ab296..d22dac83 100644
--- a/api/pkg/services/google_cloud_push_queue_service.go
+++ b/api/pkg/services/google_cloud_push_queue_service.go
@@ -6,7 +6,7 @@ import (
"net/http"
"time"
- "github.com/avast/retry-go"
+ "github.com/avast/retry-go/v5"
cloudtasks "cloud.google.com/go/cloudtasks/apiv2"
"cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb"
@@ -39,10 +39,10 @@ func NewGooglePushQueue(
// Enqueue a task to the queue
func (queue *googlePushQueue) Enqueue(ctx context.Context, task *PushQueueTask, timeout time.Duration) (queueID string, err error) {
- err = retry.Do(func() error {
+ err = retry.New(retry.Attempts(3)).Do(func() error {
queueID, err = queue.enqueueImpl(ctx, task, timeout)
return err
- }, retry.Attempts(3))
+ })
return queueID, err
}
diff --git a/api/pkg/services/marketting_service.go b/api/pkg/services/marketting_service.go
index 17eccdd6..199eb81c 100644
--- a/api/pkg/services/marketting_service.go
+++ b/api/pkg/services/marketting_service.go
@@ -43,17 +43,17 @@ func (service *MarketingService) DeleteContact(ctx context.Context, email string
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
defer span.End()
- response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"limit": "1", "search": email})
+ response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"search": email})
if err != nil {
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot search for contact with email [%s]", email)))
}
- if len(response.Contacts) == 0 {
+ if len(response.Data) == 0 {
ctxLogger.Info(fmt.Sprintf("no contact found with email [%s], skipping deletion", email))
return nil
}
- contact := response.Contacts[0]
+ contact := response.Data[0]
if _, err = service.plunkClient.Contacts.Delete(ctx, contact.ID); err != nil {
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete user with ID [%s] from contacts", contact.Data[string(semconv.EnduserIDKey)])))
}
diff --git a/api/pkg/services/message_send_schedule_service.go b/api/pkg/services/message_send_schedule_service.go
new file mode 100644
index 00000000..1a4d85ed
--- /dev/null
+++ b/api/pkg/services/message_send_schedule_service.go
@@ -0,0 +1,213 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/events"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/google/uuid"
+ "github.com/palantir/stacktrace"
+)
+
+// MessageSendScheduleService manages message send schedules for a user.
+type MessageSendScheduleService struct {
+ service
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ repository repositories.MessageSendScheduleRepository
+ dispatcher *EventDispatcher
+}
+
+// NewMessageSendScheduleService creates a new MessageSendScheduleService.
+func NewMessageSendScheduleService(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+ repository repositories.MessageSendScheduleRepository,
+ dispatcher *EventDispatcher,
+) *MessageSendScheduleService {
+ return &MessageSendScheduleService{
+ logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleService{})),
+ tracer: tracer,
+ repository: repository,
+ dispatcher: dispatcher,
+ }
+}
+
+// MessageSendScheduleUpsertParams contains the fields required to create or update a message send schedule.
+type MessageSendScheduleUpsertParams struct {
+ UserID entities.UserID
+ Name string
+ Timezone string
+ Windows []entities.MessageSendScheduleWindow
+}
+
+// Index returns all message send schedules for a user.
+func (service *MessageSendScheduleService) Index(
+ ctx context.Context,
+ userID entities.UserID,
+) ([]entities.MessageSendSchedule, error) {
+ return service.repository.Index(ctx, userID)
+}
+
+// CountByUser returns the number of schedules owned by a user.
+func (service *MessageSendScheduleService) CountByUser(
+ ctx context.Context,
+ userID entities.UserID,
+) (int, error) {
+ return service.repository.CountByUser(ctx, userID)
+}
+
+// Load returns a single message send schedule for a user.
+func (service *MessageSendScheduleService) Load(
+ ctx context.Context,
+ userID entities.UserID,
+ scheduleID uuid.UUID,
+) (*entities.MessageSendSchedule, error) {
+ return service.repository.Load(ctx, userID, scheduleID)
+}
+
+// Store creates a new message send schedule.
+func (service *MessageSendScheduleService) Store(
+ ctx context.Context,
+ params *MessageSendScheduleUpsertParams,
+) (*entities.MessageSendSchedule, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ schedule := &entities.MessageSendSchedule{
+ ID: uuid.New(),
+ UserID: params.UserID,
+ Name: params.Name,
+ Timezone: params.Timezone,
+ Windows: sanitizeWindows(params.Windows),
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+
+ if err := service.repository.Store(ctx, schedule); err != nil {
+ return nil, service.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ fmt.Sprintf("cannot store message send schedule [%s]", schedule.ID),
+ ),
+ )
+ }
+
+ return schedule, nil
+}
+
+// Update updates an existing message send schedule.
+func (service *MessageSendScheduleService) Update(
+ ctx context.Context,
+ userID entities.UserID,
+ scheduleID uuid.UUID,
+ params *MessageSendScheduleUpsertParams,
+) (*entities.MessageSendSchedule, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ schedule, err := service.repository.Load(ctx, userID, scheduleID)
+ if err != nil {
+ return nil, err
+ }
+
+ schedule.Name = params.Name
+ schedule.Timezone = params.Timezone
+ schedule.Windows = sanitizeWindows(params.Windows)
+ schedule.UpdatedAt = time.Now().UTC()
+
+ if err = service.repository.Update(ctx, schedule); err != nil {
+ return nil, service.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ fmt.Sprintf("cannot update message send schedule [%s]", schedule.ID),
+ ),
+ )
+ }
+
+ return schedule, nil
+}
+
+// Delete removes a message send schedule for a user.
+func (service *MessageSendScheduleService) Delete(
+ ctx context.Context,
+ userID entities.UserID,
+ scheduleID uuid.UUID,
+) error {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ if err := service.repository.Delete(ctx, userID, scheduleID); err != nil {
+ msg := fmt.Sprintf("cannot delete message send schedule with ID [%s] for user [%s]", scheduleID, userID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ event, err := service.createEvent(events.EventTypeMessageSendScheduleDeleted, fmt.Sprintf("%T", service), events.MessageSendScheduleDeletedPayload{
+ ScheduleID: scheduleID,
+ UserID: userID,
+ Timestamp: time.Now().UTC(),
+ })
+ if err != nil {
+ msg := fmt.Sprintf("cannot create [%s] event for schedule [%s]", events.EventTypeMessageSendScheduleDeleted, scheduleID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err = service.dispatcher.Dispatch(ctx, event); err != nil {
+ msg := fmt.Sprintf("cannot dispatch [%s] event for schedule [%s]", event.Type(), scheduleID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ return nil
+}
+
+// sanitizeWindows normalizes and sorts schedule windows by day and start minute.
+func sanitizeWindows(
+ windows []entities.MessageSendScheduleWindow,
+) []entities.MessageSendScheduleWindow {
+ result := make([]entities.MessageSendScheduleWindow, 0, len(windows))
+
+ for _, item := range windows {
+ result = append(result, entities.MessageSendScheduleWindow{
+ DayOfWeek: item.DayOfWeek,
+ StartMinute: item.StartMinute,
+ EndMinute: item.EndMinute,
+ })
+ }
+
+ sort.SliceStable(result, func(i, j int) bool {
+ if result[i].DayOfWeek == result[j].DayOfWeek {
+ return result[i].StartMinute < result[j].StartMinute
+ }
+ return result[i].DayOfWeek < result[j].DayOfWeek
+ })
+
+ return result
+}
+
+// DeleteAllForUser removes all message send schedules owned by a user.
+func (service *MessageSendScheduleService) DeleteAllForUser(
+ ctx context.Context,
+ userID entities.UserID,
+) error {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ if err := service.repository.DeleteAllForUser(ctx, userID); err != nil {
+ return service.tracer.WrapErrorSpan(
+ span,
+ stacktrace.Propagate(
+ err,
+ fmt.Sprintf("cannot delete message send schedules for user [%s]", userID),
+ ),
+ )
+ }
+
+ return nil
+}
diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go
index 5a95b265..56766c98 100644
--- a/api/pkg/services/message_service.go
+++ b/api/pkg/services/message_service.go
@@ -2,13 +2,14 @@ package services
import (
"context"
+ "encoding/base64"
"fmt"
"strings"
"time"
"github.com/davecgh/go-spew/spew"
-
"github.com/nyaruka/phonenumbers"
+ "golang.org/x/sync/errgroup"
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/httpsms/pkg/repositories"
@@ -20,14 +21,23 @@ import (
"github.com/NdoleStudio/httpsms/pkg/telemetry"
)
+// ServiceAttachment represents attachment data passed to the service layer
+type ServiceAttachment struct {
+ Name string
+ ContentType string
+ Content string // base64-encoded
+}
+
// MessageService is handles message requests
type MessageService struct {
service
- logger telemetry.Logger
- tracer telemetry.Tracer
- eventDispatcher *EventDispatcher
- phoneService *PhoneService
- repository repositories.MessageRepository
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ eventDispatcher *EventDispatcher
+ phoneService *PhoneService
+ repository repositories.MessageRepository
+ attachmentRepository repositories.AttachmentRepository
+ apiBaseURL string
}
// NewMessageService creates a new MessageService
@@ -37,13 +47,17 @@ func NewMessageService(
repository repositories.MessageRepository,
eventDispatcher *EventDispatcher,
phoneService *PhoneService,
+ attachmentRepository repositories.AttachmentRepository,
+ apiBaseURL string,
) (s *MessageService) {
return &MessageService{
- logger: logger.WithService(fmt.Sprintf("%T", s)),
- tracer: tracer,
- repository: repository,
- phoneService: phoneService,
- eventDispatcher: eventDispatcher,
+ logger: logger.WithService(fmt.Sprintf("%T", s)),
+ tracer: tracer,
+ repository: repository,
+ phoneService: phoneService,
+ eventDispatcher: eventDispatcher,
+ attachmentRepository: attachmentRepository,
+ apiBaseURL: apiBaseURL,
}
}
@@ -109,6 +123,21 @@ func (service *MessageService) DeleteAllForUser(ctx context.Context, userID enti
return nil
}
+// GetBulkMessages fetches the last bulk message summaries for a user
+func (service *MessageService) GetBulkMessages(ctx context.Context, userID entities.UserID) ([]*entities.BulkMessage, error) {
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ orders, err := service.repository.GetBulkMessages(ctx, userID, 10)
+ if err != nil {
+ msg := fmt.Sprintf("could not fetch bulk messages for user [%s]", userID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("fetched [%d] bulk messages for user [%s]", len(orders), userID))
+ return orders, nil
+}
+
// DeleteMessage deletes a message from the database
func (service *MessageService) DeleteMessage(ctx context.Context, source string, message *entities.Message) error {
ctx, span := service.tracer.Start(ctx)
@@ -290,14 +319,15 @@ func (service *MessageService) StoreEvent(ctx context.Context, message *entities
// MessageReceiveParams parameters registering a message event
type MessageReceiveParams struct {
- Contact string
- UserID entities.UserID
- Owner phonenumbers.PhoneNumber
- Content string
- SIM entities.SIM
- Timestamp time.Time
- Encrypted bool
- Source string
+ Contact string
+ UserID entities.UserID
+ Owner phonenumbers.PhoneNumber
+ Content string
+ SIM entities.SIM
+ Timestamp time.Time
+ Encrypted bool
+ Source string
+ Attachments []ServiceAttachment
}
// ReceiveMessage handles message received by a mobile phone
@@ -307,15 +337,25 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa
ctxLogger := service.tracer.CtxLogger(service.logger, span)
+ messageID := uuid.New()
+
+ ctxLogger.Info(fmt.Sprintf("uploading [%d] attachments for user [%s] message [%s]", len(params.Attachments), params.UserID, messageID))
+ attachmentURLs, err := service.uploadAttachments(ctx, params.UserID, messageID, params.Attachments)
+ if err != nil {
+ msg := fmt.Sprintf("cannot upload attachments for user [%s] message [%s]", params.UserID, messageID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
eventPayload := events.MessagePhoneReceivedPayload{
- MessageID: uuid.New(),
- UserID: params.UserID,
- Encrypted: params.Encrypted,
- Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164),
- Contact: params.Contact,
- Timestamp: params.Timestamp,
- Content: params.Content,
- SIM: params.SIM,
+ MessageID: messageID,
+ UserID: params.UserID,
+ Encrypted: params.Encrypted,
+ Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164),
+ Contact: params.Contact,
+ Timestamp: params.Timestamp,
+ Content: params.Content,
+ SIM: params.SIM,
+ Attachments: attachmentURLs,
}
ctxLogger.Info(fmt.Sprintf("creating cloud event for received with ID [%s]", eventPayload.MessageID))
@@ -332,7 +372,7 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa
msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
- ctxLogger.Info(fmt.Sprintf("event [%s] dispatched succesfully", event.ID()))
+ ctxLogger.Info(fmt.Sprintf("event [%s] dispatched successfully", event.ID()))
return service.storeReceivedMessage(ctx, eventPayload)
}
@@ -430,11 +470,13 @@ type MessageSendParams struct {
Contact string
Encrypted bool
Content string
+ Attachments []string
Source string
SendAt *time.Time
RequestID *string
UserID entities.UserID
RequestReceivedAt time.Time
+ Index int
}
// SendMessage a new message
@@ -444,7 +486,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe
ctxLogger := service.tracer.CtxLogger(service.logger, span)
- sendAttempts, sim := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164))
+ sendAttempts, sim, messagesPerMinute := service.phoneSettings(ctx, params.UserID, phonenumbers.Format(params.Owner, phonenumbers.E164))
eventPayload := events.MessageAPISentPayload{
MessageID: uuid.New(),
@@ -456,7 +498,9 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe
Contact: params.Contact,
RequestReceivedAt: params.RequestReceivedAt,
Content: params.Content,
+ Attachments: params.Attachments,
ScheduledSendTime: params.SendAt,
+ ExactSendTime: params.SendAt != nil,
SIM: sim,
}
@@ -473,7 +517,7 @@ func (service *MessageService) SendMessage(ctx context.Context, params MessageSe
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
- timeout := service.getSendDelay(ctxLogger, eventPayload, params.SendAt)
+ timeout := service.getSendDelay(ctxLogger, eventPayload, params, messagesPerMinute)
if _, err = service.eventDispatcher.DispatchWithTimeout(ctx, event, timeout); err != nil {
msg := fmt.Sprintf("cannot dispatch event type [%s] and id [%s]", event.Type(), event.ID())
return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
@@ -532,18 +576,24 @@ func (service *MessageService) RegisterMissedCall(ctx context.Context, params *M
return message, err
}
-func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, sendAt *time.Time) time.Duration {
- if sendAt == nil {
- return time.Duration(0)
+func (service *MessageService) getSendDelay(ctxLogger telemetry.Logger, eventPayload events.MessageAPISentPayload, params MessageSendParams, messagesPerMinute uint) time.Duration {
+ if params.SendAt != nil {
+ delay := params.SendAt.Sub(time.Now().UTC())
+ if delay < 0 {
+ ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, params.SendAt.String()))
+ return time.Duration(0)
+ }
+ return delay
}
- delay := sendAt.Sub(time.Now().UTC())
- if delay < 0 {
- ctxLogger.Info(fmt.Sprintf("message [%s] has send time [%s] in the past. sending immediately", eventPayload.MessageID, sendAt.String()))
- return time.Duration(0)
+ if params.Index > 0 && messagesPerMinute > 0 {
+ interval := time.Minute / time.Duration(messagesPerMinute)
+ delay := time.Duration(params.Index) * interval
+ ctxLogger.Info(fmt.Sprintf("message [%s] bulk index [%d] rate-based delay [%s]", eventPayload.MessageID, params.Index, delay))
+ return delay
}
- return delay
+ return time.Duration(0)
}
// StoreReceivedMessage a new message
@@ -559,6 +609,7 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params
UserID: params.UserID,
Contact: params.Contact,
Content: params.Content,
+ Attachments: params.Attachments,
SIM: params.SIM,
Encrypted: params.Encrypted,
Type: entities.MessageTypeMobileOriginated,
@@ -579,6 +630,55 @@ func (service *MessageService) storeReceivedMessage(ctx context.Context, params
return message, nil
}
+func (service *MessageService) uploadAttachments(ctx context.Context, userID entities.UserID, messageID uuid.UUID, attachments []ServiceAttachment) ([]string, error) {
+ if len(attachments) == 0 {
+ return []string{}, nil
+ }
+
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ g, gCtx := errgroup.WithContext(ctx)
+ urls := make([]string, len(attachments))
+ paths := make([]string, len(attachments))
+
+ for i, attachment := range attachments {
+ i, attachment := i, attachment
+ g.Go(func() error {
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot decode base64 content for attachment [%d]", i))
+ }
+
+ sanitizedName := repositories.SanitizeFilename(attachment.Name, i)
+ ext := repositories.ExtensionFromContentType(attachment.ContentType)
+ filename := sanitizedName + ext
+
+ path := fmt.Sprintf("attachments/%s/%s/%d/%s", userID, messageID, i, filename)
+ paths[i] = path
+
+ if err = service.attachmentRepository.Upload(gCtx, path, decoded, attachment.ContentType); err != nil {
+ return stacktrace.Propagate(err, fmt.Sprintf("cannot upload attachment [%d] to path [%s]", i, path))
+ }
+
+ urls[i] = fmt.Sprintf("%s/v1/attachments/%s/%s/%d/%s", service.apiBaseURL, userID, messageID, i, filename)
+ ctxLogger.Info(fmt.Sprintf("uploaded attachment [%d] to [%s]", i, path))
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ for _, path := range paths {
+ if path != "" {
+ _ = service.attachmentRepository.Delete(ctx, path)
+ }
+ }
+ return nil, stacktrace.Propagate(err, "cannot upload attachments")
+ }
+
+ return urls, nil
+}
+
// HandleMessageParams are parameters for handling a message event
type HandleMessageParams struct {
ID uuid.UUID
@@ -934,7 +1034,7 @@ func (service *MessageService) SearchMessages(ctx context.Context, params *Messa
return messages, nil
}
-func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM) {
+func (service *MessageService) phoneSettings(ctx context.Context, userID entities.UserID, owner string) (uint, entities.SIM, uint) {
ctx, span := service.tracer.Start(ctx)
defer span.End()
@@ -944,10 +1044,10 @@ func (service *MessageService) phoneSettings(ctx context.Context, userID entitie
if err != nil {
msg := fmt.Sprintf("cannot load phone for userID [%s] and owner [%s]. using default max send attempt of 2", userID, owner)
ctxLogger.Error(stacktrace.Propagate(err, msg))
- return 2, entities.SIM1
+ return 2, entities.SIM1, 0
}
- return phone.MaxSendAttemptsSanitized(), phone.SIM
+ return phone.MaxSendAttemptsSanitized(), phone.SIM, phone.MessagesPerMinute
}
// storeSentMessage a new message
@@ -968,6 +1068,7 @@ func (service *MessageService) storeSentMessage(ctx context.Context, payload eve
Contact: payload.Contact,
UserID: payload.UserID,
Content: payload.Content,
+ Attachments: payload.Attachments,
RequestID: payload.RequestID,
SIM: payload.SIM,
Encrypted: payload.Encrypted,
diff --git a/api/pkg/services/message_service_test.go b/api/pkg/services/message_service_test.go
new file mode 100644
index 00000000..263816f4
--- /dev/null
+++ b/api/pkg/services/message_service_test.go
@@ -0,0 +1,105 @@
+package services
+
+import (
+ "testing"
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/events"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "go.opentelemetry.io/otel/trace"
+)
+
+func TestGetSendDelay_WithSendAt_ReturnsTimeUntil(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ sendAt := time.Now().UTC().Add(5 * time.Minute)
+ params := MessageSendParams{SendAt: &sendAt}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ delay := service.getSendDelay(logger, payload, params, 10)
+
+ // Should be approximately 5 minutes (within 2 seconds tolerance)
+ assert.InDelta(t, float64(5*time.Minute), float64(delay), float64(2*time.Second))
+}
+
+func TestGetSendDelay_WithSendAtInPast_ReturnsZero(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ sendAt := time.Now().UTC().Add(-5 * time.Minute)
+ params := MessageSendParams{SendAt: &sendAt}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ delay := service.getSendDelay(logger, payload, params, 10)
+
+ assert.Equal(t, time.Duration(0), delay)
+}
+
+func TestGetSendDelay_BulkIndex_RateBasedDelay(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ params := MessageSendParams{Index: 3}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ // 10 messages per minute = 6 seconds interval
+ delay := service.getSendDelay(logger, payload, params, 10)
+
+ expected := time.Duration(3) * (time.Minute / time.Duration(10))
+ assert.Equal(t, expected, delay)
+}
+
+func TestGetSendDelay_BulkIndex_ZeroRate_ReturnsZero(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ params := MessageSendParams{Index: 5}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ delay := service.getSendDelay(logger, payload, params, 0)
+
+ assert.Equal(t, time.Duration(0), delay)
+}
+
+func TestGetSendDelay_IndexZero_ReturnsZero(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ params := MessageSendParams{Index: 0}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ delay := service.getSendDelay(logger, payload, params, 10)
+
+ assert.Equal(t, time.Duration(0), delay)
+}
+
+func TestGetSendDelay_NoSendAtNoIndex_ReturnsZero(t *testing.T) {
+ service := &MessageService{}
+ logger := &noopLogger{}
+
+ params := MessageSendParams{}
+ payload := events.MessageAPISentPayload{MessageID: uuid.New()}
+
+ delay := service.getSendDelay(logger, payload, params, 10)
+
+ assert.Equal(t, time.Duration(0), delay)
+}
+
+// noopLogger implements telemetry.Logger for testing
+type noopLogger struct{}
+
+var _ telemetry.Logger = (*noopLogger)(nil)
+
+func (l *noopLogger) Error(_ error) {}
+func (l *noopLogger) WithService(_ string) telemetry.Logger { return l }
+func (l *noopLogger) WithString(_, _ string) telemetry.Logger { return l }
+func (l *noopLogger) WithSpan(_ trace.SpanContext) telemetry.Logger { return l }
+func (l *noopLogger) Trace(_ string) {}
+func (l *noopLogger) Info(_ string) {}
+func (l *noopLogger) Warn(_ error) {}
+func (l *noopLogger) Debug(_ string) {}
+func (l *noopLogger) Fatal(_ error) {}
+func (l *noopLogger) Printf(_ string, _ ...interface{}) {}
diff --git a/api/pkg/services/phone_api_key_service.go b/api/pkg/services/phone_api_key_service.go
index 5a148583..c9283fdd 100644
--- a/api/pkg/services/phone_api_key_service.go
+++ b/api/pkg/services/phone_api_key_service.go
@@ -40,6 +40,14 @@ func NewPhoneAPIKeyService(
}
}
+// CountByUser returns the number of phone API keys owned by a user.
+func (service *PhoneAPIKeyService) CountByUser(ctx context.Context, userID entities.UserID) (int, error) {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ return service.repository.CountByUser(ctx, userID)
+}
+
// Index fetches the entities.Webhook for an entities.UserID
func (service *PhoneAPIKeyService) Index(ctx context.Context, userID entities.UserID, params repositories.IndexParams) ([]*entities.PhoneAPIKey, error) {
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go
index 7907d6d6..79b43037 100644
--- a/api/pkg/services/phone_notification_service.go
+++ b/api/pkg/services/phone_notification_service.go
@@ -15,35 +15,39 @@ import (
"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/google/uuid"
"github.com/palantir/stacktrace"
+ "go.opentelemetry.io/otel/trace"
)
// PhoneNotificationService sends out notifications to mobile phones
type PhoneNotificationService struct {
service
- logger telemetry.Logger
- tracer telemetry.Tracer
- phoneNotificationRepository repositories.PhoneNotificationRepository
- phoneRepository repositories.PhoneRepository
- messagingClient *messaging.Client
- eventDispatcher *EventDispatcher
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ phoneNotificationRepository repositories.PhoneNotificationRepository
+ phoneRepository repositories.PhoneRepository
+ messageSendScheduleRepository repositories.MessageSendScheduleRepository
+ messagingClient FCMClient
+ eventDispatcher *EventDispatcher
}
// NewNotificationService creates a new PhoneNotificationService
func NewNotificationService(
logger telemetry.Logger,
tracer telemetry.Tracer,
- messagingClient *messaging.Client,
+ messagingClient FCMClient,
phoneRepository repositories.PhoneRepository,
phoneNotificationRepository repositories.PhoneNotificationRepository,
+ messageSendScheduleRepository repositories.MessageSendScheduleRepository,
dispatcher *EventDispatcher,
) (s *PhoneNotificationService) {
return &PhoneNotificationService{
- logger: logger.WithService(fmt.Sprintf("%T", s)),
- tracer: tracer,
- messagingClient: messagingClient,
- phoneNotificationRepository: phoneNotificationRepository,
- phoneRepository: phoneRepository,
- eventDispatcher: dispatcher,
+ logger: logger.WithService(fmt.Sprintf("%T", &PhoneNotificationService{})),
+ tracer: tracer,
+ messagingClient: messagingClient,
+ phoneNotificationRepository: phoneNotificationRepository,
+ phoneRepository: phoneRepository,
+ messageSendScheduleRepository: messageSendScheduleRepository,
+ eventDispatcher: dispatcher,
}
}
@@ -61,6 +65,20 @@ func (service *PhoneNotificationService) DeleteAllForUser(ctx context.Context, u
return nil
}
+// DeleteByMessageID deletes all entities.PhoneNotification for a user and message ID.
+func (service *PhoneNotificationService) DeleteByMessageID(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error {
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ if err := service.phoneNotificationRepository.DeleteByMessageID(ctx, userID, messageID); err != nil {
+ msg := fmt.Sprintf("could not delete [entities.PhoneNotification] for user [%s] and message with ID [%s]", userID, messageID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("deleted [entities.PhoneNotification] for user [%s] and message with ID [%s]", userID, messageID))
+ return nil
+}
+
// SendHeartbeatFCM sends a heartbeat message so the phone can request a heartbeat
func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, payload *events.PhoneHeartbeatMissedPayload) error {
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
@@ -92,7 +110,13 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p
return nil
}
- ctxLogger.Info(fmt.Sprintf("successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]", result, payload.PhoneID, payload.UserID, payload.MonitorID))
+ ctxLogger.Info(fmt.Sprintf(
+ "successfully sent heartbeat FCM [%s] to phone with ID [%s] for user [%s] and monitor [%s]",
+ result,
+ payload.PhoneID,
+ payload.UserID,
+ payload.MonitorID,
+ ))
return nil
}
@@ -134,7 +158,15 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone
Token: *phone.FcmToken,
})
if err != nil {
- ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]", phone.ID, phone.UserID, params.MessageID)))
+ ctxLogger.Warn(stacktrace.Propagate(
+ err,
+ fmt.Sprintf(
+ "cannot send FCM to phone with ID [%s] for user with ID [%s] and message [%s]",
+ phone.ID,
+ phone.UserID,
+ params.MessageID,
+ ),
+ ))
msg := fmt.Sprintf("cannot send notification for to your phone [%s]. Reinstall the httpSMS app on your Android phone.", phone.PhoneNumber)
return service.handleNotificationFailed(ctx, errors.New(msg), params)
}
@@ -144,14 +176,16 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone
// PhoneNotificationScheduleParams are parameters for sending a notification
type PhoneNotificationScheduleParams struct {
- UserID entities.UserID
- Owner string
- Source string
- Encrypted bool
- Contact string
- Content string
- SIM entities.SIM
- MessageID uuid.UUID
+ UserID entities.UserID
+ Owner string
+ Source string
+ Encrypted bool
+ Contact string
+ Content string
+ SIM entities.SIM
+ MessageID uuid.UUID
+ ExactSendTime bool
+ ScheduledSendTime *time.Time
}
// Schedule a notification to be sent to a phone
@@ -178,7 +212,24 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P
UpdatedAt: time.Now().UTC(),
}
- if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, notification); err != nil {
+ if params.ExactSendTime && params.ScheduledSendTime != nil {
+ return service.scheduleExact(ctx, span, ctxLogger, params, phone, notification)
+ }
+
+ var schedule *entities.MessageSendSchedule
+ if phone.MessageSendScheduleID != nil {
+ schedule, err = service.messageSendScheduleRepository.Load(ctx, params.UserID, *phone.MessageSendScheduleID)
+ if err != nil && stacktrace.GetCode(err) != repositories.ErrCodeNotFound {
+ msg := fmt.Sprintf("cannot load send schedule [%s] for phone [%s]", *phone.MessageSendScheduleID, phone.ID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+ }
+
+ if schedule != nil {
+ ctxLogger.Info(fmt.Sprintf("loaded [%T] with ID [%s] for phone [%s]", schedule, schedule.ID, phone.ID))
+ }
+
+ if err = service.phoneNotificationRepository.Schedule(ctx, phone.MessagesPerMinute, schedule, notification); err != nil {
msg := fmt.Sprintf("cannot schedule notification for message [%s] to phone [%s]", params.MessageID, phone.ID)
return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
@@ -191,11 +242,57 @@ func (service *PhoneNotificationService) Schedule(ctx context.Context, params *P
return service.tracer.WrapErrorSpan(span, err)
}
- ctxLogger.Info(fmt.Sprintf("message with id [%s] notification scheduled for [%s] with id [%s]", params.MessageID, notification.ScheduledAt, notification.ID))
+ ctxLogger.Info(fmt.Sprintf(
+ "message with id [%s] notification scheduled for [%s] with id [%s] with phone schedule ID [%s]",
+ params.MessageID,
+ notification.ScheduledAt,
+ notification.ID,
+ phone.MessageSendScheduleID,
+ ))
+ return nil
+}
+
+func (service *PhoneNotificationService) scheduleExact(
+ ctx context.Context,
+ span trace.Span,
+ ctxLogger telemetry.Logger,
+ params *PhoneNotificationScheduleParams,
+ phone *entities.Phone,
+ notification *entities.PhoneNotification,
+) error {
+ scheduledAt := *params.ScheduledSendTime
+ if scheduledAt.Before(time.Now().UTC()) {
+ scheduledAt = time.Now().UTC()
+ }
+ notification.ScheduledAt = scheduledAt
+
+ if err := service.phoneNotificationRepository.ScheduleExact(ctx, notification); err != nil {
+ msg := fmt.Sprintf("cannot schedule exact notification for message [%s] to phone [%s]", params.MessageID, phone.ID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if err := service.dispatchMessageNotificationScheduled(ctx, params, notification); err != nil {
+ ctxLogger.Error(err)
+ }
+
+ if err := service.dispatchMessageNotificationSend(ctx, params.Source, notification); err != nil {
+ return service.tracer.WrapErrorSpan(span, err)
+ }
+
+ ctxLogger.Info(fmt.Sprintf(
+ "message with id [%s] exact notification scheduled for [%s] with id [%s]",
+ params.MessageID,
+ notification.ScheduledAt,
+ notification.ID,
+ ))
return nil
}
-func (service *PhoneNotificationService) dispatchMessageNotificationSend(ctx context.Context, source string, notification *entities.PhoneNotification) error {
+func (service *PhoneNotificationService) dispatchMessageNotificationSend(
+ ctx context.Context,
+ source string,
+ notification *entities.PhoneNotification,
+) error {
event, err := service.createMessageNotificationSendEvent(source, &events.MessageNotificationSendPayload{
MessageID: notification.MessageID,
UserID: notification.UserID,
@@ -213,7 +310,11 @@ func (service *PhoneNotificationService) dispatchMessageNotificationSend(ctx con
return nil
}
-func (service *PhoneNotificationService) dispatchMessageNotificationScheduled(ctx context.Context, params *PhoneNotificationScheduleParams, notification *entities.PhoneNotification) error {
+func (service *PhoneNotificationService) dispatchMessageNotificationScheduled(
+ ctx context.Context,
+ params *PhoneNotificationScheduleParams,
+ notification *entities.PhoneNotification,
+) error {
event, err := service.createMessageNotificationScheduledEvent(params.Source, &events.MessageNotificationScheduledPayload{
MessageID: notification.MessageID,
Owner: params.Owner,
@@ -258,7 +359,12 @@ func (service *PhoneNotificationService) handleNotificationFailed(ctx context.Co
return nil
}
-func (service *PhoneNotificationService) handleNotificationSent(ctx context.Context, phone *entities.Phone, result string, params *PhoneNotificationSendParams) error {
+func (service *PhoneNotificationService) handleNotificationSent(
+ ctx context.Context,
+ phone *entities.Phone,
+ result string,
+ params *PhoneNotificationSendParams,
+) error {
ctx, span := service.tracer.Start(ctx)
defer span.End()
@@ -279,15 +385,26 @@ func (service *PhoneNotificationService) handleNotificationSent(ctx context.Cont
return nil
}
-func (service *PhoneNotificationService) createMessageNotificationScheduledEvent(source string, payload *events.MessageNotificationScheduledPayload) (cloudevents.Event, error) {
+func (service *PhoneNotificationService) createMessageNotificationScheduledEvent(
+ source string,
+ payload *events.MessageNotificationScheduledPayload,
+) (cloudevents.Event, error) {
return service.createEvent(events.EventTypeMessageNotificationScheduled, source, payload)
}
-func (service *PhoneNotificationService) createMessageNotificationSendEvent(source string, payload *events.MessageNotificationSendPayload) (cloudevents.Event, error) {
+func (service *PhoneNotificationService) createMessageNotificationSendEvent(
+ source string,
+ payload *events.MessageNotificationSendPayload,
+) (cloudevents.Event, error) {
return service.createEvent(events.EventTypeMessageNotificationSend, source, payload)
}
-func (service *PhoneNotificationService) createMessageNotificationSentEvent(source string, phone *entities.Phone, fcmMessageID string, params *PhoneNotificationSendParams) (cloudevents.Event, error) {
+func (service *PhoneNotificationService) createMessageNotificationSentEvent(
+ source string,
+ phone *entities.Phone,
+ fcmMessageID string,
+ params *PhoneNotificationSendParams,
+) (cloudevents.Event, error) {
event := cloudevents.NewEvent()
event.SetSource(source)
@@ -314,7 +431,11 @@ func (service *PhoneNotificationService) createMessageNotificationSentEvent(sour
return event, nil
}
-func (service *PhoneNotificationService) createMessageNotificationFailedEvent(source string, errorMessage string, params *PhoneNotificationSendParams) (cloudevents.Event, error) {
+func (service *PhoneNotificationService) createMessageNotificationFailedEvent(
+ source string,
+ errorMessage string,
+ params *PhoneNotificationSendParams,
+) (cloudevents.Event, error) {
event := cloudevents.NewEvent()
event.SetSource(source)
@@ -339,7 +460,11 @@ func (service *PhoneNotificationService) createMessageNotificationFailedEvent(so
return event, nil
}
-func (service *PhoneNotificationService) updateStatus(ctx context.Context, notificationID uuid.UUID, status entities.PhoneNotificationStatus) {
+func (service *PhoneNotificationService) updateStatus(
+ ctx context.Context,
+ notificationID uuid.UUID,
+ status entities.PhoneNotificationStatus,
+) {
ctx, span := service.tracer.Start(ctx)
defer span.End()
@@ -347,9 +472,9 @@ func (service *PhoneNotificationService) updateStatus(ctx context.Context, notif
err := service.phoneNotificationRepository.UpdateStatus(ctx, notificationID, status)
if err != nil {
- msg := fmt.Sprintf("cannot update status of notificaiton with id [%s] to [%s]", notificationID, status)
+ msg := fmt.Sprintf("cannot update status of notification with id [%s] to [%s]", notificationID, status)
ctxLogger.Error(stacktrace.Propagate(err, msg))
}
- ctxLogger.Info(fmt.Sprintf("updated status of notificaiton with id [%s] to [%s]", notificationID, status))
+ ctxLogger.Info(fmt.Sprintf("updated status of notification with id [%s] to [%s]", notificationID, status))
}
diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go
index df8e2104..ae863d32 100644
--- a/api/pkg/services/phone_service.go
+++ b/api/pkg/services/phone_service.go
@@ -56,6 +56,20 @@ func (service *PhoneService) DeleteAllForUser(ctx context.Context, userID entiti
return nil
}
+// NullifyScheduleID sets MessageSendScheduleID to NULL for all phones referencing the given schedule.
+func (service *PhoneService) NullifyScheduleID(ctx context.Context, userID entities.UserID, scheduleID uuid.UUID) error {
+ ctx, span := service.tracer.Start(ctx)
+ defer span.End()
+
+ if err := service.repository.NullifyScheduleID(ctx, userID, scheduleID); err != nil {
+ msg := fmt.Sprintf("cannot nullify schedule ID [%s] for user [%s]", scheduleID, userID)
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ service.tracer.CtxLogger(service.logger, span).Info(fmt.Sprintf("nullified schedule ID [%s] on phones for user [%s]", scheduleID, userID))
+ return nil
+}
+
// Index fetches the heartbeats for a phone number
func (service *PhoneService) Index(ctx context.Context, authUser entities.AuthContext, params repositories.IndexParams) (*[]entities.Phone, error) {
ctx, span := service.tracer.Start(ctx)
@@ -91,6 +105,7 @@ type PhoneUpsertParams struct {
MessageExpirationDuration *time.Duration
MissedCallAutoReply *string
SIM entities.SIM
+ MessageSendScheduleID *uuid.UUID
Source string
UserID entities.UserID
}
@@ -105,12 +120,13 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara
phone, err := service.repository.Load(ctx, params.UserID, phonenumbers.Format(params.PhoneNumber, phonenumbers.E164))
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
return service.createPhone(ctx, &PhoneFCMTokenParams{
- Source: params.Source,
- PhoneNumber: params.PhoneNumber,
- PhoneAPIKeyID: nil,
- UserID: params.UserID,
- FcmToken: params.FcmToken,
- SIM: params.SIM,
+ Source: params.Source,
+ PhoneNumber: params.PhoneNumber,
+ PhoneAPIKeyID: nil,
+ UserID: params.UserID,
+ FcmToken: params.FcmToken,
+ SIM: params.SIM,
+ MessageSendScheduleID: params.MessageSendScheduleID,
})
}
@@ -126,12 +142,13 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara
ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID))
return phone, service.dispatchPhoneUpdatedEvent(ctx, phone, &PhoneFCMTokenParams{
- Source: params.Source,
- PhoneNumber: params.PhoneNumber,
- PhoneAPIKeyID: nil,
- UserID: params.UserID,
- FcmToken: params.FcmToken,
- SIM: params.SIM,
+ Source: params.Source,
+ PhoneNumber: params.PhoneNumber,
+ PhoneAPIKeyID: nil,
+ UserID: params.UserID,
+ FcmToken: params.FcmToken,
+ SIM: params.SIM,
+ MessageSendScheduleID: params.MessageSendScheduleID,
})
}
@@ -201,12 +218,13 @@ func (service *PhoneService) Delete(ctx context.Context, source string, userID e
// PhoneFCMTokenParams are parameters for upserting an entities.Phone
type PhoneFCMTokenParams struct {
- Source string
- PhoneNumber *phonenumbers.PhoneNumber
- PhoneAPIKeyID *uuid.UUID
- UserID entities.UserID
- FcmToken *string
- SIM entities.SIM
+ Source string
+ PhoneNumber *phonenumbers.PhoneNumber
+ PhoneAPIKeyID *uuid.UUID
+ UserID entities.UserID
+ FcmToken *string
+ SIM entities.SIM
+ MessageSendScheduleID *uuid.UUID
}
// UpsertFCMToken the FCM token for an entities.Phone
@@ -251,6 +269,7 @@ func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTo
MaxSendAttempts: 2,
SIM: params.SIM,
MissedCallAutoReply: nil,
+ MessageSendScheduleID: params.MessageSendScheduleID,
PhoneNumber: phonenumbers.Format(params.PhoneNumber, phonenumbers.E164),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
@@ -294,6 +313,7 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa
}
phone.SIM = params.SIM
+ phone.MessageSendScheduleID = params.MessageSendScheduleID
return phone
}
diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go
index e030e1f8..20c924b4 100644
--- a/api/pkg/services/user_service.go
+++ b/api/pkg/services/user_service.go
@@ -3,13 +3,13 @@ package services
import (
"context"
"fmt"
+ "io"
+ "net/http"
"time"
"firebase.google.com/go/auth"
-
- "github.com/NdoleStudio/httpsms/pkg/events"
-
"github.com/NdoleStudio/httpsms/pkg/emails"
+ "github.com/NdoleStudio/httpsms/pkg/events"
"github.com/NdoleStudio/lemonsqueezy-go"
"github.com/NdoleStudio/httpsms/pkg/repositories"
@@ -31,6 +31,7 @@ type UserService struct {
dispatcher *EventDispatcher
authClient *auth.Client
lemonsqueezyClient *lemonsqueezy.Client
+ httpClient *http.Client
}
// NewUserService creates a new UserService
@@ -43,6 +44,7 @@ func NewUserService(
lemonsqueezyClient *lemonsqueezy.Client,
dispatcher *EventDispatcher,
authClient *auth.Client,
+ httpClient *http.Client,
) (s *UserService) {
return &UserService{
logger: logger.WithService(fmt.Sprintf("%T", s)),
@@ -53,7 +55,80 @@ func NewUserService(
dispatcher: dispatcher,
authClient: authClient,
lemonsqueezyClient: lemonsqueezyClient,
+ httpClient: httpClient,
+ }
+}
+
+// GetSubscriptionPayments fetches the subscription payments for an entities.User
+func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID entities.UserID) (invoices []lemonsqueezy.ApiResponseData[lemonsqueezy.SubscriptionInvoiceAttributes, lemonsqueezy.APIResponseRelationshipsSubscriptionInvoice], err error) {
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ user, err := service.repository.Load(ctx, userID)
+ if err != nil {
+ msg := fmt.Sprintf("could not get [%T] with with ID [%s]", user, userID)
+ return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ if user.SubscriptionID == nil {
+ ctxLogger.Info(fmt.Sprintf("no subscription ID found for [%T] with ID [%s], returning empty invoices", user, user.ID))
+ return invoices, nil
+ }
+
+ ctxLogger.Info(fmt.Sprintf("fetching subscription payments for [%T] with ID [%s] and subscription [%s]", user, user.ID, *user.SubscriptionID))
+ invoicesResponse, _, err := service.lemonsqueezyClient.SubscriptionInvoices.List(ctx, map[string]string{"filter[subscription_id]": *user.SubscriptionID})
+ if err != nil {
+ msg := fmt.Sprintf("could not get invoices for subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID)
+ return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
}
+
+ ctxLogger.Info(fmt.Sprintf("fetched [%d] payments for [%T] with ID [%s] and subscription ID [%s]", len(invoicesResponse.Data), user, user.ID, *user.SubscriptionID))
+ return invoicesResponse.Data, nil
+}
+
+// UserInvoiceGenerateParams are parameters for generating a subscription payment invoice
+type UserInvoiceGenerateParams struct {
+ UserID entities.UserID
+ SubscriptionInvoiceID string
+ Name string
+ Address string
+ City string
+ State string
+ Country string
+ ZipCode string
+ Notes string
+}
+
+// GenerateReceipt generates a receipt for a subscription payment.
+func (service *UserService) GenerateReceipt(ctx context.Context, params *UserInvoiceGenerateParams) (io.Reader, error) {
+ ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
+ defer span.End()
+
+ payload := map[string]string{
+ "name": params.Name,
+ "address": params.Address,
+ "city": params.City,
+ "state": params.State,
+ "country": params.Country,
+ "zip_code": params.ZipCode,
+ "notes": params.Notes,
+ "locale": "en",
+ }
+
+ invoice, _, err := service.lemonsqueezyClient.SubscriptionInvoices.Generate(ctx, params.SubscriptionInvoiceID, payload)
+ if err != nil {
+ msg := fmt.Sprintf("could not generate subscription payment invoice user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ response, err := service.httpClient.Get(invoice.Meta.Urls.DownloadInvoice)
+ if err != nil {
+ msg := fmt.Sprintf("could not download subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)
+ return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))
+ }
+
+ ctxLogger.Info(fmt.Sprintf("generated subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID))
+ return response.Body, nil
}
// Get fetches or creates an entities.User
diff --git a/api/pkg/services/webhook_service.go b/api/pkg/services/webhook_service.go
index 2e94cc06..30fb0c1f 100644
--- a/api/pkg/services/webhook_service.go
+++ b/api/pkg/services/webhook_service.go
@@ -11,7 +11,7 @@ import (
"sync"
"time"
- "github.com/avast/retry-go"
+ "github.com/avast/retry-go/v5"
"github.com/pkg/errors"
"github.com/gofiber/fiber/v2"
@@ -212,7 +212,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud
defer span.End()
attempts := 0
- err := retry.Do(func() error {
+ err := retry.New(retry.Attempts(2)).Do(func() error {
attempts++
requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
@@ -252,7 +252,7 @@ func (service *WebhookService) sendNotification(ctx context.Context, event cloud
ctxLogger.Info(fmt.Sprintf("sent webhook to url [%s] for event [%s] with ID [%s] and response code [%d]", webhook.URL, event.Type(), event.ID(), response.StatusCode))
return nil
- }, retry.Attempts(2))
+ })
if err != nil {
msg := fmt.Sprintf("cannot handle [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts)
ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
@@ -367,7 +367,7 @@ func (service *WebhookService) handleWebhookSendFailed(ctx context.Context, even
}
if errors.Is(err, context.DeadlineExceeded) {
- payload.ErrorMessage = "TIMOUT after 10 seconds"
+ payload.ErrorMessage = "TIMEOUT after 10 seconds"
}
if response != nil {
diff --git a/api/pkg/telemetry/gorm_logger.go b/api/pkg/telemetry/gorm_logger.go
index 10ed03c7..3cf02162 100644
--- a/api/pkg/telemetry/gorm_logger.go
+++ b/api/pkg/telemetry/gorm_logger.go
@@ -27,15 +27,15 @@ func (gorm *gormLogger) LogMode(_ logger.LogLevel) logger.Interface {
return gorm
}
-func (gorm *gormLogger) Info(ctx context.Context, s string, i ...interface{}) {
+func (gorm *gormLogger) Info(ctx context.Context, s string, i ...any) {
gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Info(fmt.Sprintf(s, i...))
}
-func (gorm *gormLogger) Warn(ctx context.Context, s string, i ...interface{}) {
+func (gorm *gormLogger) Warn(ctx context.Context, s string, i ...any) {
gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Warn(fmt.Errorf(s, i...))
}
-func (gorm *gormLogger) Error(ctx context.Context, s string, i ...interface{}) {
+func (gorm *gormLogger) Error(ctx context.Context, s string, i ...any) {
gorm.logger.WithSpan(gorm.tracer.Span(ctx).SpanContext()).Error(fmt.Errorf(s, i...))
}
diff --git a/api/pkg/telemetry/zerolog_logger.go b/api/pkg/telemetry/zerolog_logger.go
index c6cd8b68..c5230bc0 100644
--- a/api/pkg/telemetry/zerolog_logger.go
+++ b/api/pkg/telemetry/zerolog_logger.go
@@ -5,7 +5,7 @@ import (
"github.com/hirosassa/zerodriver"
"github.com/rs/zerolog"
- semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
+ semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
"go.opentelemetry.io/otel/trace"
)
@@ -34,7 +34,7 @@ func NewZerologLogger(projectID string, fields map[string]string, driver *zerodr
func (logger *zerologLogger) WithService(service string) Logger {
return NewZerologLogger(
logger.projectID,
- logger.addField(string(semconv.ServiceNameKey), service),
+ logger.addField(string(semconv.ServiceNamespaceKey), service),
logger.zerolog,
logger.spanContext,
)
@@ -96,7 +96,9 @@ func (logger *zerologLogger) WithSpan(spanContext trace.SpanContext) Logger {
func (logger *zerologLogger) decorateEvent(event *zerodriver.Event) *zerolog.Event {
if logger.spanContext != nil {
- event.TraceContext(logger.spanContext.TraceID().String(), logger.spanContext.SpanID().String(), logger.spanContext.IsSampled(), logger.projectID)
+ event.Str("trace_id", logger.spanContext.TraceID().String())
+ event.Str("span_id", logger.spanContext.SpanID().String())
+ event.Bool("trace_sampled", logger.spanContext.IsSampled())
}
for key, value := range logger.fields {
event.Str(key, value)
diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go
index 9881c53f..67537a93 100644
--- a/api/pkg/validators/bulk_message_handler_validator.go
+++ b/api/pkg/validators/bulk_message_handler_validator.go
@@ -12,6 +12,7 @@ import (
"github.com/xuri/excelize/v2"
+ "github.com/NdoleStudio/httpsms/pkg/cache"
"github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/requests"
@@ -30,6 +31,7 @@ type BulkMessageHandlerValidator struct {
userService *services.UserService
logger telemetry.Logger
tracer telemetry.Tracer
+ cache cache.Cache
}
// NewBulkMessageHandlerValidator creates a new handlers.BulkMessageHandlerValidator validator
@@ -38,17 +40,19 @@ func NewBulkMessageHandlerValidator(
tracer telemetry.Tracer,
phoneService *services.PhoneService,
userService *services.UserService,
+ appCache cache.Cache,
) (v *BulkMessageHandlerValidator) {
return &BulkMessageHandlerValidator{
logger: logger.WithService(fmt.Sprintf("%T", v)),
tracer: tracer,
userService: userService,
phoneService: phoneService,
+ cache: appCache,
}
}
// ValidateStore validates the requests.BillingUsageHistory request
-func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
+func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, *time.Location, url.Values) {
ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger)
defer span.End()
@@ -57,39 +61,39 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID
result := url.Values{}
result.Add("document", "Cannot load your account. Please try again later or contact support.")
ctxLogger.Error(v.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s]", userID))))
- return nil, result
+ return nil, nil, result
}
messages, result := v.parseFile(ctxLogger, user, header)
if len(result) != 0 {
- return messages, result
+ return messages, user.Location(), result
}
if len(messages) == 0 {
result.Add("document", "The uploaded file doesn't contain any valid records. Make sure you are using the official httpSMS template.")
- return messages, result
+ return messages, user.Location(), result
}
if len(messages) > 1000 {
result.Add("document", "The uploaded file must contain less than 1000 records.")
- return messages, result
+ return messages, user.Location(), result
}
for index, message := range messages {
messages[index] = message.Sanitize()
}
- result = v.validateMessages(messages)
+ result = v.validateMessages(ctx, messages, user.Location())
if len(result) != 0 {
- return messages, result
+ return messages, user.Location(), result
}
result = v.validateOwners(ctx, userID, messages)
if len(result) != 0 {
- return messages, result
+ return messages, user.Location(), result
}
- return messages, result
+ return messages, user.Location(), result
}
func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) {
@@ -119,6 +123,7 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user
result.Add("document", fmt.Sprintf("Cannot parse the uploaded excel file with name [%s].", header.Filename))
return nil, result
}
+ defer excel.Close()
rows, err := excel.GetRows(excel.GetSheetName(0))
if err != nil {
@@ -133,36 +138,35 @@ func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user
continue
}
- var sendAt *time.Time
+ var sendTimeRaw string
if len(row) > 3 && strings.TrimSpace(row[3]) != "" {
ctxLogger.Info(fmt.Sprintf("excel time = [%s]", row[3]))
- sendAt, err = v.convertExcelTime(user, row[3])
- if err != nil {
+ msg := &requests.BulkMessage{SendTime: strings.TrimSpace(row[3])}
+ sendAt := msg.GetSendTime(user.Location())
+ if sendAt == nil {
result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] is not in the correct format e.g [2006-01-02T15:04:05] where 2006 is the year, 01 is January, 02 is the second day of the month and the time is 15:04:05", index+1, row[3]))
return nil, result
}
+ sendTimeRaw = sendAt.Format(time.RFC3339)
+ }
+
+ var attachmentURLs string
+ if len(row) > 4 && strings.TrimSpace(row[4]) != "" {
+ attachmentURLs = strings.TrimSpace(row[4])
}
messages = append(messages, &requests.BulkMessage{
FromPhoneNumber: strings.TrimSpace(row[0]),
ToPhoneNumber: strings.TrimSpace(row[1]),
Content: row[2],
- SendTime: sendAt,
+ SendTime: sendTimeRaw,
+ AttachmentURLs: attachmentURLs,
})
}
return messages, url.Values{}
}
-func (v *BulkMessageHandlerValidator) convertExcelTime(user *entities.User, value string) (*time.Time, error) {
- t, err := time.ParseInLocation("2006-01-02T15:04:05", value, user.Location())
- if err != nil {
- return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot parse excel time [%s] as [%T]", value, t))
- }
-
- return &t, nil
-}
-
func (v *BulkMessageHandlerValidator) parseBytes(ctxLogger telemetry.Logger, userID entities.UserID, header *multipart.FileHeader) ([]byte, url.Values) {
result := url.Values{}
@@ -202,16 +206,46 @@ func (v *BulkMessageHandlerValidator) parseCSV(ctxLogger telemetry.Logger, user
var messages []*requests.BulkMessage
if err := csvutil.Unmarshal(content, &messages); err != nil {
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot unmarshall contents [%s] into type [%T] for file [%s] and user [%s]", content, messages, header.Filename, user.ID)))
- result.Add("document", fmt.Sprintf("Cannot read the conents of the uploaded file [%s].", header.Filename))
+ result.Add("document", fmt.Sprintf("Cannot read the contents of the uploaded file [%s].", header.Filename))
return nil, result
}
return messages, url.Values{}
}
-func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.BulkMessage) url.Values {
+func (v *BulkMessageHandlerValidator) validateMessages(_ context.Context, messages []*requests.BulkMessage, location *time.Location) url.Values {
result := url.Values{}
for index, message := range messages {
+
+ if message.AttachmentURLs != "" {
+ urls := strings.Split(message.AttachmentURLs, ",")
+
+ validAttachmentCount := 0
+ for _, u := range urls {
+ if strings.TrimSpace(u) != "" {
+ validAttachmentCount++
+ }
+ }
+
+ if validAttachmentCount > 10 {
+ result.Add("document", fmt.Sprintf("Row [%d]: You cannot attach more than 10 files per message.", index+2))
+ }
+
+ for _, u := range urls {
+ cleanURL := strings.TrimSpace(u)
+ if cleanURL == "" {
+ continue
+ }
+
+ parsedURL, err := url.ParseRequestURI(cleanURL)
+ if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
+ result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] has an invalid url format.", index+2, cleanURL))
+ } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
+ result.Add("document", fmt.Sprintf("Row [%d]: The attachment URL [%s] must use http or https.", index+2, cleanURL))
+ }
+ }
+ }
+
if _, err := phonenumbers.Parse(message.FromPhoneNumber, phonenumbers.UNKNOWN_REGION); err != nil {
result.Add("document", fmt.Sprintf("Row [%d]: The FromPhoneNumber [%s] is not a valid E.164 phone number", index+2, message.FromPhoneNumber))
}
@@ -224,8 +258,13 @@ func (v *BulkMessageHandlerValidator) validateMessages(messages []*requests.Bulk
result.Add("document", fmt.Sprintf("Row [%d]: The message content must be less than 1024 characters.", index+2))
}
- if message.SendTime != nil && message.SendTime.After(time.Now().Add(420*time.Hour)) {
- result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] cannot be more than 20 days (420 hours) in the future.", index+2, message.SendTime.Format(time.RFC3339)))
+ if strings.TrimSpace(message.SendTime) != "" {
+ sendTime := message.GetSendTime(location)
+ if sendTime == nil {
+ result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] is not a valid date format. Use RFC3339 (e.g. 2023-11-11T02:10:01Z) or YYYY-MM-DDTHH:MM:SS.", index+2, message.SendTime))
+ } else if sendTime.After(time.Now().Add(420 * time.Hour)) {
+ result.Add("document", fmt.Sprintf("Row [%d]: The SendTime [%s] cannot be more than 20 days (420 hours) in the future.", index+2, sendTime.Format(time.RFC3339)))
+ }
}
}
return result
diff --git a/api/pkg/validators/message_handler_validator.go b/api/pkg/validators/message_handler_validator.go
index 33ab4dfe..da6a7a1d 100644
--- a/api/pkg/validators/message_handler_validator.go
+++ b/api/pkg/validators/message_handler_validator.go
@@ -2,11 +2,13 @@ package validators
import (
"context"
+ "encoding/base64"
"fmt"
"net/url"
"strings"
"time"
+ "github.com/NdoleStudio/httpsms/pkg/cache"
"github.com/NdoleStudio/httpsms/pkg/repositories"
"github.com/NdoleStudio/httpsms/pkg/services"
"github.com/palantir/stacktrace"
@@ -25,6 +27,7 @@ type MessageHandlerValidator struct {
tracer telemetry.Tracer
phoneService *services.PhoneService
tokenValidator *TurnstileTokenValidator
+ cache cache.Cache
}
// NewMessageHandlerValidator creates a new handlers.MessageHandler validator
@@ -33,15 +36,23 @@ func NewMessageHandlerValidator(
tracer telemetry.Tracer,
phoneService *services.PhoneService,
tokenValidator *TurnstileTokenValidator,
+ appCache cache.Cache,
) (v *MessageHandlerValidator) {
return &MessageHandlerValidator{
logger: logger.WithService(fmt.Sprintf("%T", v)),
tracer: tracer,
phoneService: phoneService,
tokenValidator: tokenValidator,
+ cache: appCache,
}
}
+const (
+ maxAttachmentCount = 10
+ maxAttachmentSize = (3 * 1024 * 1024) / 2 // 1.5 MB per attachment
+ maxTotalAttachmentSize = 3 * 1024 * 1024 // 3 MB total
+)
+
// ValidateMessageReceive validates the requests.MessageReceive request
func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Context, request requests.MessageReceive) url.Values {
v := govalidator.New(govalidator.Options{
@@ -54,11 +65,12 @@ func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Contex
"from": []string{
"required",
},
- "content": []string{
- "required",
- "min:1",
- "max:2048",
- },
+ "content": func() []string {
+ if len(request.Attachments) > 0 {
+ return []string{"max:2048"}
+ }
+ return []string{"required", "min:1", "max:2048"}
+ }(),
"sim": []string{
"required",
"in:" + strings.Join([]string{
@@ -69,7 +81,54 @@ func (validator MessageHandlerValidator) ValidateMessageReceive(_ context.Contex
},
})
- return v.ValidateStruct()
+ errors := v.ValidateStruct()
+
+ if len(request.Attachments) > 0 {
+ attachmentErrors := validator.validateAttachments(request.Attachments)
+ for key, values := range attachmentErrors {
+ for _, value := range values {
+ errors.Add(key, value)
+ }
+ }
+ }
+
+ return errors
+}
+
+func (validator MessageHandlerValidator) validateAttachments(attachments []requests.MessageAttachment) url.Values {
+ errors := url.Values{}
+ allowedTypes := repositories.AllowedContentTypes()
+
+ if len(attachments) > maxAttachmentCount {
+ errors.Add("attachments", fmt.Sprintf("attachment count [%d] exceeds maximum of [%d]", len(attachments), maxAttachmentCount))
+ return errors
+ }
+
+ totalSize := 0
+ for i, attachment := range attachments {
+ if !allowedTypes[attachment.ContentType] {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has unsupported content type [%s]", i, attachment.ContentType))
+ continue
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(attachment.Content)
+ if err != nil {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] has invalid base64 content", i))
+ continue
+ }
+
+ if len(decoded) > maxAttachmentSize {
+ errors.Add("attachments", fmt.Sprintf("attachment [%d] size [%d] exceeds maximum of [%d] bytes", i, len(decoded), maxAttachmentSize))
+ }
+
+ totalSize += len(decoded)
+ }
+
+ if totalSize > maxTotalAttachmentSize {
+ errors.Add("attachments", fmt.Sprintf("total attachment size [%d] exceeds maximum of [%d] bytes", totalSize, maxTotalAttachmentSize))
+ }
+
+ return errors
}
// ValidateMessageSend validates the requests.MessageSend request
@@ -93,6 +152,10 @@ func (validator MessageHandlerValidator) ValidateMessageSend(ctx context.Context
"required",
phoneNumberRule,
},
+ "attachments": []string{
+ "max:10",
+ multipleAttachmentURLRule,
+ },
"content": []string{
"required",
"min:1",
@@ -143,6 +206,10 @@ func (validator MessageHandlerValidator) ValidateMessageBulkSend(ctx context.Con
"required",
phoneNumberRule,
},
+ "attachments": []string{
+ "max:10",
+ multipleAttachmentURLRule,
+ },
"content": []string{
"required",
"min:1",
@@ -261,7 +328,7 @@ func (validator MessageHandlerValidator) ValidateMessageSearch(ctx context.Conte
"min:0",
},
"query": []string{
- "max:20",
+ "max:50",
},
"token": []string{
"required",
diff --git a/api/pkg/validators/message_send_schedule_handler_validator.go b/api/pkg/validators/message_send_schedule_handler_validator.go
new file mode 100644
index 00000000..fa850de1
--- /dev/null
+++ b/api/pkg/validators/message_send_schedule_handler_validator.go
@@ -0,0 +1,165 @@
+package validators
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "sort"
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/requests"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/thedevsaddam/govalidator"
+)
+
+const maxWindowsPerDay = 6
+
+// MessageSendScheduleHandlerValidator validates send schedule HTTP requests.
+type MessageSendScheduleHandlerValidator struct {
+ validator
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+}
+
+// NewMessageSendScheduleHandlerValidator creates a new MessageSendScheduleHandlerValidator.
+func NewMessageSendScheduleHandlerValidator(
+ logger telemetry.Logger,
+ tracer telemetry.Tracer,
+) *MessageSendScheduleHandlerValidator {
+ return &MessageSendScheduleHandlerValidator{
+ logger: logger.WithService(fmt.Sprintf("%T", &MessageSendScheduleHandlerValidator{})),
+ tracer: tracer,
+ }
+}
+
+// ValidateStore validates a send schedule create or update request.
+func (validator *MessageSendScheduleHandlerValidator) ValidateStore(
+ _ context.Context,
+ request requests.MessageSendScheduleStore,
+) url.Values {
+ v := govalidator.New(govalidator.Options{
+ Data: &request,
+ Rules: govalidator.MapData{
+ "name": []string{"required", "min:2", "max:100"},
+ "timezone": []string{"required", "min:2", "max:100"},
+ },
+ })
+
+ result := v.ValidateStruct()
+ validator.validateWindows(result, request.Windows)
+
+ if request.Timezone != "" {
+ if _, err := time.LoadLocation(request.Timezone); err != nil {
+ result.Add("timezone", "The timezone must be a valid IANA timezone e.g Europe/London.")
+ }
+ }
+
+ return result
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateWindows(
+ result url.Values,
+ windows []requests.MessageSendScheduleWindow,
+) {
+ if len(windows) == 0 {
+ result.Add("windows", "at least one active window is required")
+ return
+ }
+
+ windowsPerDay := make(map[int]int)
+
+ for index, item := range windows {
+ validator.validateDayOfWeek(result, index, item, windowsPerDay)
+ validator.validateStartMinute(result, index, item)
+ validator.validateEndMinute(result, index, item)
+ validator.validateWindowRange(result, index, item)
+ }
+
+ validator.validateOverlappingWindows(result, windows)
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateDayOfWeek(
+ result url.Values,
+ index int,
+ item requests.MessageSendScheduleWindow,
+ windowsPerDay map[int]int,
+) {
+ if item.DayOfWeek < 0 || item.DayOfWeek > 6 {
+ result.Add("windows", fmt.Sprintf("windows[%d].day_of_week must be between 0 and 6", index))
+ return
+ }
+
+ windowsPerDay[item.DayOfWeek]++
+ if windowsPerDay[item.DayOfWeek] > maxWindowsPerDay {
+ result.Add(
+ "windows",
+ fmt.Sprintf("day_of_week %d cannot have more than %d windows", item.DayOfWeek, maxWindowsPerDay),
+ )
+ }
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateStartMinute(
+ result url.Values,
+ index int,
+ item requests.MessageSendScheduleWindow,
+) {
+ if item.StartMinute < 0 || item.StartMinute > 1439 {
+ result.Add("windows", fmt.Sprintf("windows[%d].start_minute must be between 0 and 1439", index))
+ }
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateEndMinute(
+ result url.Values,
+ index int,
+ item requests.MessageSendScheduleWindow,
+) {
+ if item.EndMinute < 1 || item.EndMinute > 1440 {
+ result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be between 1 and 1440", index))
+ }
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateWindowRange(
+ result url.Values,
+ index int,
+ item requests.MessageSendScheduleWindow,
+) {
+ if item.EndMinute <= item.StartMinute {
+ result.Add("windows", fmt.Sprintf("windows[%d].end_minute must be greater than start_minute", index))
+ }
+}
+
+func (validator *MessageSendScheduleHandlerValidator) validateOverlappingWindows(
+ result url.Values,
+ windows []requests.MessageSendScheduleWindow,
+) {
+ grouped := make(map[int][]requests.MessageSendScheduleWindow)
+
+ for _, item := range windows {
+ if item.DayOfWeek < 0 || item.DayOfWeek > 6 {
+ continue
+ }
+ if item.EndMinute <= item.StartMinute {
+ continue
+ }
+ grouped[item.DayOfWeek] = append(grouped[item.DayOfWeek], item)
+ }
+
+ for dayOfWeek, dayWindows := range grouped {
+ sort.Slice(dayWindows, func(i, j int) bool {
+ return dayWindows[i].StartMinute < dayWindows[j].StartMinute
+ })
+
+ for i := 1; i < len(dayWindows); i++ {
+ previous := dayWindows[i-1]
+ current := dayWindows[i]
+
+ if current.StartMinute < previous.EndMinute {
+ result.Add(
+ "windows",
+ fmt.Sprintf("day_of_week %d contains overlapping windows", dayOfWeek),
+ )
+ break
+ }
+ }
+ }
+}
diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go
index 2369214e..e9d4274e 100644
--- a/api/pkg/validators/phone_handler_validator.go
+++ b/api/pkg/validators/phone_handler_validator.go
@@ -7,27 +7,31 @@ import (
"strings"
"github.com/NdoleStudio/httpsms/pkg/entities"
-
"github.com/NdoleStudio/httpsms/pkg/requests"
+ "github.com/NdoleStudio/httpsms/pkg/services"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/google/uuid"
"github.com/thedevsaddam/govalidator"
)
// PhoneHandlerValidator validates models used in handlers.PhoneHandler
type PhoneHandlerValidator struct {
validator
- logger telemetry.Logger
- tracer telemetry.Tracer
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ scheduleService *services.MessageSendScheduleService
}
// NewPhoneHandlerValidator creates a new handlers.PhoneHandler validator
func NewPhoneHandlerValidator(
logger telemetry.Logger,
tracer telemetry.Tracer,
+ scheduleService *services.MessageSendScheduleService,
) (v *PhoneHandlerValidator) {
return &PhoneHandlerValidator{
- logger: logger.WithService(fmt.Sprintf("%T", v)),
- tracer: tracer,
+ logger: logger.WithService(fmt.Sprintf("%T", v)),
+ tracer: tracer,
+ scheduleService: scheduleService,
}
}
@@ -56,7 +60,7 @@ func (validator *PhoneHandlerValidator) ValidateIndex(_ context.Context, request
}
// ValidateUpsert validates requests.PhoneUpsert
-func (validator *PhoneHandlerValidator) ValidateUpsert(_ context.Context, request requests.PhoneUpsert) url.Values {
+func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, userID entities.UserID, request requests.PhoneUpsert) url.Values {
v := govalidator.New(govalidator.Options{
Data: &request,
Rules: govalidator.MapData{
@@ -84,16 +88,26 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(_ context.Context, reques
"min:60",
"max:3600",
},
+ "message_send_schedule_id": []string{
+ "uuid",
+ },
},
})
result := v.ValidateStruct()
+ if request.MaxSendAttempts > 0 && request.MessageExpirationSeconds == 0 {
+ result.Add("message_expiration_seconds", "message_expiration_seconds cannot be 0 when max_send_attempts is greater than 0")
+ }
+
if len(result) > 0 {
return result
}
- if request.MaxSendAttempts > 0 && request.MessageExpirationSeconds == 0 {
- result.Add("message_expiration_seconds", "message_expiration_seconds cannot be 0 when max_send_attempts is greater than 0")
+ if strings.TrimSpace(request.MessageSendScheduleID) != "" {
+ scheduleID, _ := uuid.Parse(strings.TrimSpace(request.MessageSendScheduleID))
+ if _, err := validator.scheduleService.Load(ctx, userID, scheduleID); err != nil {
+ result.Add("message_send_schedule_id", "The message_send_schedule_id does not belong to the authenticated user or does not exist")
+ }
}
return result
diff --git a/api/pkg/validators/user_handler_validator.go b/api/pkg/validators/user_handler_validator.go
index 553a1cd8..4c05bd1b 100644
--- a/api/pkg/validators/user_handler_validator.go
+++ b/api/pkg/validators/user_handler_validator.go
@@ -5,26 +5,32 @@ import (
"fmt"
"net/url"
+ "github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/requests"
+ "github.com/NdoleStudio/httpsms/pkg/services"
"github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/palantir/stacktrace"
"github.com/thedevsaddam/govalidator"
)
// UserHandlerValidator validates models used in handlers.UserHandler
type UserHandlerValidator struct {
validator
- logger telemetry.Logger
- tracer telemetry.Tracer
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ service *services.UserService
}
// NewUserHandlerValidator creates a new handlers.UserHandler validator
func NewUserHandlerValidator(
logger telemetry.Logger,
tracer telemetry.Tracer,
+ service *services.UserService,
) (v *UserHandlerValidator) {
return &UserHandlerValidator{
- logger: logger.WithService(fmt.Sprintf("%T", v)),
- tracer: tracer,
+ service: service,
+ logger: logger.WithService(fmt.Sprintf("%T", v)),
+ tracer: tracer,
}
}
@@ -41,3 +47,83 @@ func (validator *UserHandlerValidator) ValidateUpdate(_ context.Context, request
return v.ValidateStruct()
}
+
+// ValidatePaymentInvoice validates the requests.UserPaymentInvoice request
+func (validator *UserHandlerValidator) ValidatePaymentInvoice(ctx context.Context, userID entities.UserID, request requests.UserPaymentInvoice) url.Values {
+ ctx, span, ctxLogger := validator.tracer.StartWithLogger(ctx, validator.logger)
+ defer span.End()
+
+ rules := govalidator.MapData{
+ "name": []string{
+ "required",
+ "min:1",
+ "max:100",
+ },
+ "address": []string{
+ "required",
+ "min:1",
+ "max:200",
+ },
+ "city": []string{
+ "required",
+ "min:1",
+ "max:100",
+ },
+ "state": []string{
+ "min:1",
+ "max:100",
+ },
+ "country": []string{
+ "required",
+ "len:2",
+ },
+ "zip_code": []string{
+ "required",
+ "min:1",
+ "max:20",
+ },
+ "notes": []string{
+ "max:1000",
+ },
+ }
+ if request.Country == "CA" {
+ rules["state"] = []string{
+ "required",
+ "in:AB,BC,MB,NB,NL,NS,NT,NU,ON,PE,QC,SK,YT",
+ }
+ }
+
+ if request.Country == "US" {
+ rules["state"] = []string{
+ "required",
+ "in:AL,AK,AZ,AR,CA,CO,CT,DE,FL,GA,HI,ID,IL,IN,IA,KS,KY,LA,ME,MD,MA,MI,MN,MS,MO,MT,NE,NV,NH,NJ,NM,NY,NC,ND,OH,OK,OR,PA,RI,SC,SD,TN,TX,UT,VT,VA,WA,WV,WI,WY",
+ }
+ }
+
+ v := govalidator.New(govalidator.Options{
+ Data: &request,
+ Rules: rules,
+ })
+
+ validationErrors := v.ValidateStruct()
+ if len(validationErrors) > 0 {
+ return validationErrors
+ }
+
+ payments, err := validator.service.GetSubscriptionPayments(ctx, userID)
+ if err != nil {
+ msg := fmt.Sprintf("cannot get subscription payments for user with ID [%s]", userID)
+ ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)))
+ validationErrors.Add("subscriptionInvoiceID", "failed to validate subscription payment invoice ID")
+ return validationErrors
+ }
+
+ for _, payment := range payments {
+ if payment.ID == request.SubscriptionInvoiceID {
+ return validationErrors
+ }
+ }
+
+ validationErrors.Add("subscriptionInvoiceID", "failed to validate the subscription payment invoice ID")
+ return validationErrors
+}
diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go
index bc7111e8..1fcb716a 100644
--- a/api/pkg/validators/validator.go
+++ b/api/pkg/validators/validator.go
@@ -1,11 +1,15 @@
package validators
import (
+ "context"
"fmt"
+ "net/http"
"net/url"
"regexp"
"strings"
+ "time"
+ "github.com/NdoleStudio/httpsms/pkg/cache"
"github.com/NdoleStudio/httpsms/pkg/events"
"github.com/nyaruka/phonenumbers"
@@ -19,6 +23,7 @@ const (
multiplePhoneNumberRule = "multiplePhoneNumber"
contactPhoneNumberRule = "contactPhoneNumber"
multipleContactPhoneNumberRule = "multipleContactPhoneNumber"
+ multipleAttachmentURLRule = "multipleAttachmentURL"
multipleInRule = "multipleIn"
webhookEventsRule = "webhookEvents"
)
@@ -86,6 +91,21 @@ func init() {
return nil
})
+ govalidator.AddCustomRule(multipleAttachmentURLRule, func(field string, rule string, message string, value interface{}) error {
+ attachments, ok := value.([]string)
+ if !ok {
+ return fmt.Errorf("The %s field must be an array of valid attachment URLs", field)
+ }
+
+ for index, attachment := range attachments {
+ u, err := url.ParseRequestURI(attachment)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return fmt.Errorf("The attachment %d with URL [%s] must be a valid URL e.g https://placehold.co/600x400", index, attachment)
+ }
+ }
+ return nil
+ })
+
govalidator.AddCustomRule(multipleInRule, func(field string, rule string, message string, value interface{}) error {
values, ok := value.([]string)
if !ok {
@@ -104,7 +124,7 @@ func init() {
for index, item := range values {
if !contains(item) {
- return fmt.Errorf("the %s field in contains an invalid value [%s] at index [%d] ", field, item, index)
+ return fmt.Errorf("the %s field in contains an invalid value [%s] at index [%d]", field, item, index)
}
}
@@ -160,3 +180,54 @@ func (validator *validator) ValidateUUID(ID string, name string) url.Values {
return v.ValidateStruct()
}
+
+func validateAttachmentURL(ctx context.Context, c cache.Cache, attachmentURL string) error {
+ cacheKey := "mms-url-validation:" + attachmentURL
+
+ if cachedVal, err := c.Get(ctx, cacheKey); err == nil {
+ if cachedVal == "valid" {
+ return nil
+ }
+ return fmt.Errorf(cachedVal)
+ }
+
+ client := &http.Client{
+ Timeout: 5 * time.Second,
+ }
+
+ req, err := http.NewRequest(http.MethodHead, attachmentURL, nil)
+ if err != nil {
+ errMsg := fmt.Sprintf("invalid url format")
+ saveToCache(ctx, c, cacheKey, errMsg)
+ return fmt.Errorf(errMsg)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ errMsg := fmt.Sprintf("could not reach the url")
+ saveToCache(ctx, c, cacheKey, errMsg)
+ return fmt.Errorf(errMsg)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 400 {
+ errMsg := fmt.Sprintf("url returned an error status code: %d", resp.StatusCode)
+ saveToCache(ctx, c, cacheKey, errMsg)
+ return fmt.Errorf(errMsg)
+ }
+
+ const maxSizeBytes = 1.5 * 1024 * 1024
+
+ if resp.ContentLength > int64(maxSizeBytes) {
+ errMsg := fmt.Sprintf("file size (%.2f MB) exceeds the 1.5 MB carrier limit", float64(resp.ContentLength)/(1024*1024))
+ saveToCache(ctx, c, cacheKey, errMsg)
+ return fmt.Errorf(errMsg)
+ }
+
+ saveToCache(ctx, c, cacheKey, "valid")
+ return nil
+}
+
+func saveToCache(ctx context.Context, c cache.Cache, key string, value string) {
+ _ = c.Set(ctx, key, value, 15*time.Minute)
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 16e885e9..ef63287d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3.8"
-
services:
postgres:
image: postgres:alpine
@@ -13,7 +11,7 @@ services:
- "5435:5432"
restart: on-failure
healthcheck:
- test: ["CMD-SHELL", "pg_isready", "-U", "dbusername", "-d", "httpsms"]
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 30s
timeout: 60s
retries: 5
diff --git a/docs/superpowers/specs/2026-05-26-nuxt4-vuetify4-migration-design.md b/docs/superpowers/specs/2026-05-26-nuxt4-vuetify4-migration-design.md
new file mode 100644
index 00000000..bc982b57
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-26-nuxt4-vuetify4-migration-design.md
@@ -0,0 +1,385 @@
+# httpSMS Frontend Migration: Nuxt 2 + Vuetify 2 → Nuxt 4 + Vuetify 4
+
+## Summary
+
+Migrate the `web/` frontend from Nuxt 2 (Vue 2, Vuetify 2, Vuex, class-based components) to Nuxt 4 (Vue 3, Vuetify 4, Pinia, `
+```
+
+**After (Vue 3):**
+```vue
+
+```
+
+#### Vuetify Breakpoints: `$vuetify.breakpoint` → `useDisplay()`
+
+**Before:**
+```vue
+
+```
+
+**After:**
+```vue
+
+
+
+
+```
+
+#### State: Vuex → Pinia
+
+**Before:**
+```ts
+this.$store.dispatch('loadPhones', true)
+this.$store.getters.getAuthUser
+```
+
+**After:**
+```ts
+const phonesStore = usePhonesStore()
+await phonesStore.loadPhones(true)
+phonesStore.authUser
+```
+
+#### Firebase: `this.$fire.auth` → VueFire composables
+
+**Before:**
+```ts
+await this.$fire.auth.currentUser?.getIdToken()
+```
+
+**After:**
+```ts
+import { useCurrentUser } from 'vuefire'
+const user = useCurrentUser()
+const token = await user.value?.getIdToken()
+```
+
+#### Dynamic Routes: `_id` → `[id]`
+
+- `pages/threads/_id/index.vue` → `pages/threads/[id]/index.vue`
+- `pages/heartbeats/_id.vue` → `pages/heartbeats/[id].vue`
+
+### Vuetify 4 Breaking Changes to Address
+
+Using the Vuetify MCP for each component, the key changes are:
+
+1. **CSS Layers** — mandatory in v4; adjust any custom style overrides
+2. **Theme** — default is now "system" (we want dark, configure explicitly)
+3. **Typography** — MD2 → MD3 type scale (text-h1 → text-display-large, etc.)
+4. **Breakpoints** — reduced default sizes (restore v3 values via config)
+5. **Elevation** — 25 levels → 6 levels (MD3)
+6. **VBtn** — no default uppercase, grid → flex layout
+7. **VSnackbar** — removed multi-line prop
+8. **VSelect** — "item" slot → "internalItem"
+9. **Grid** — v-row/v-col overhauled
+10. **CSS Reset** — mostly removed, add selective resets
+
+### Vuetify MCP Usage Per Component
+
+For EVERY component/page being migrated, the implementation must:
+1. Call `vuetify-mcp-get_component_api_by_version` for each Vuetify component used
+2. Call `vuetify-mcp-get_v4_breaking_changes` filtered by relevant category
+3. Apply the correct v4 API (props, slots, events) based on MCP output
+4. Verify no deprecated props/events remain
+
+### Pinia Store Design
+
+Split the monolithic Vuex store into domain stores:
+
+| Store | Responsibility |
+|-------|---------------|
+| `auth.ts` | Firebase auth state, user profile, onAuthStateChanged |
+| `messages.ts` | Messages CRUD, search |
+| `threads.ts` | Message threads, current thread |
+| `phones.ts` | Phone list, heartbeats, polling |
+| `billing.ts` | Usage, subscription, payments |
+| `notifications.ts` | Toast/snackbar queue |
+| `app.ts` | App metadata, polling state, runtime config |
+
+### Plugin Migrations
+
+| Old Plugin | New Approach |
+|-----------|-------------|
+| `plugins/axios.ts` | `composables/useApi.ts` using `$fetch` with auth header |
+| `plugins/filters.ts` | `utils/filters.ts` (import explicitly or app.config globalProperties) |
+| `plugins/vue-glow.ts` | `plugins/vue-glow.client.ts` (client-only plugin) |
+| `plugins/chart.ts` | `plugins/chart.client.ts` (client-only plugin) |
+| `plugins/errors.ts` | `utils/errors.ts` |
+| `plugins/bag.ts` | `utils/bag.ts` |
+| `plugins/capitalize.ts` | `utils/capitalize.ts` |
+| `plugins/veutify.ts` | `plugins/vuetify.ts` (createVuetify setup) |
+
+## Migration Order (Tasks)
+
+### Phase 1: Scaffold & Configuration
+1. Initialize fresh Nuxt 4 project in `web/` (backup old code)
+2. Install dependencies (vuetify, pinia, nuxt-vuefire, sass, @mdi/js, pusher-js, etc.)
+3. Configure `nuxt.config.ts` (SSG, runtime config, modules)
+4. Set up Vuetify plugin with dark theme, restored breakpoints, MDI SVG icons
+5. Set up nuxt-vuefire with Firebase config
+6. Configure TypeScript strictly
+
+### Phase 2: Foundation
+7. Port `shared/types/` (API models — mostly copy)
+8. Port `utils/` (errors, filters, bag, capitalize)
+9. Create `composables/useApi.ts` (replace Axios plugin)
+10. Create `composables/useAuth.ts` (Firebase auth helpers)
+
+### Phase 3: State Management
+11. Create Pinia store: `stores/auth.ts`
+12. Create Pinia store: `stores/notifications.ts`
+13. Create Pinia store: `stores/app.ts`
+14. Create Pinia store: `stores/phones.ts`
+15. Create Pinia store: `stores/messages.ts`
+16. Create Pinia store: `stores/threads.ts`
+17. Create Pinia store: `stores/billing.ts`
+
+### Phase 4: Layouts & Middleware
+18. Port `middleware/auth.ts`
+19. Port `middleware/guest.ts`
+20. Port `layouts/default.vue` (with Vuetify MCP)
+21. Port `layouts/website.vue` (with Vuetify MCP)
+22. Port `layouts/error.vue` (with Vuetify MCP)
+23. Create `app.vue`
+
+### Phase 5: Components (use Vuetify MCP for each)
+24. Port `components/Toast.vue`
+25. Port `components/LoadingDashboard.vue`
+26. Port `components/LoadingButton.vue`
+27. Port `components/BackButton.vue`
+28. Port `components/CopyButton.vue`
+29. Port `components/FixedHeader.vue`
+30. Port `components/BlogAuthorBio.vue`
+31. Port `components/BlogInfo.vue`
+32. Port `components/NuxtLogo.vue`
+33. Port `components/FirebaseAuth.vue`
+34. Port `components/MessageThread.vue`
+35. Port `components/MessageThreadHeader.vue`
+
+### Phase 6: Pages (use Vuetify MCP for each)
+36. Port `pages/index.vue` (homepage)
+37. Port `pages/login.vue`
+38. Port `pages/threads/index.vue`
+39. Port `pages/threads/[id]/index.vue`
+40. Port `pages/messages/index.vue`
+41. Port `pages/search-messages/index.vue`
+42. Port `pages/bulk-messages/index.vue`
+43. Port `pages/settings/index.vue`
+44. Port `pages/billing/index.vue`
+45. Port `pages/heartbeats/[id].vue`
+46. Port `pages/phone-api-keys/index.vue`
+47. Port `pages/privacy-policy/index.vue`
+48. Port `pages/terms-and-conditions/index.vue`
+49. Port `pages/blog/index.vue`
+50. Port `pages/blog/how-to-send-sms-messages-from-excel.vue`
+51. Port `pages/blog/grant-send-and-read-sms-permissions-on-android.vue`
+52. Port `pages/blog/forward-incoming-sms-from-phone-to-webhook.vue`
+53. Port `pages/blog/end-to-end-encryption-to-sms-messages.vue`
+54. Port `pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue`
+55. Port `pages/blog/send-sms-from-android-phone-with-python.vue`
+56. Port `pages/blog/send-sms-when-new-row-is-added-to-google-sheets-using-zapier.vue`
+
+### Phase 7: Final Setup
+57. Port static assets (`public/`)
+58. Port environment files (`.env`, `.env.production`)
+59. Update Dockerfile and nginx.conf
+60. Update sitemap configuration
+61. Configure highlight.js (nuxt-highlightjs or manual)
+
+### Phase 8: Verification (EVERY component and page)
+62. Verify `app.vue` renders
+63. Verify `layouts/default.vue` renders correctly
+64. Verify `layouts/website.vue` renders correctly
+65. Verify `layouts/error.vue` renders correctly
+66. Verify `components/Toast.vue` renders correctly
+67. Verify `components/LoadingDashboard.vue` renders correctly
+68. Verify `components/LoadingButton.vue` renders correctly
+69. Verify `components/BackButton.vue` renders correctly
+70. Verify `components/CopyButton.vue` renders correctly
+71. Verify `components/FixedHeader.vue` renders correctly
+72. Verify `components/BlogAuthorBio.vue` renders correctly
+73. Verify `components/BlogInfo.vue` renders correctly
+74. Verify `components/NuxtLogo.vue` renders correctly
+75. Verify `components/FirebaseAuth.vue` renders correctly
+76. Verify `components/MessageThread.vue` renders correctly
+77. Verify `components/MessageThreadHeader.vue` renders correctly
+78. Verify `pages/index.vue` renders correctly
+79. Verify `pages/login.vue` renders correctly
+80. Verify `pages/threads/index.vue` renders correctly
+81. Verify `pages/threads/[id]/index.vue` renders correctly
+82. Verify `pages/messages/index.vue` renders correctly
+83. Verify `pages/search-messages/index.vue` renders correctly
+84. Verify `pages/bulk-messages/index.vue` renders correctly
+85. Verify `pages/settings/index.vue` renders correctly
+86. Verify `pages/billing/index.vue` renders correctly
+87. Verify `pages/heartbeats/[id].vue` renders correctly
+88. Verify `pages/phone-api-keys/index.vue` renders correctly
+89. Verify `pages/privacy-policy/index.vue` renders correctly
+90. Verify `pages/terms-and-conditions/index.vue` renders correctly
+91. Verify `pages/blog/index.vue` renders correctly
+92. Verify all blog subpages render correctly
+93. Run `pnpm build` (static generation) successfully
+94. Verify no TypeScript errors (`pnpm typecheck`)
+95. Verify lint passes (`pnpm lint`)
+
+## Verification Strategy
+
+Each verification task in Phase 8 means:
+1. Start the dev server (`pnpm dev`)
+2. Navigate to the page/route in question
+3. Confirm no console errors, no hydration mismatches
+4. Confirm visual layout matches intent (Vuetify components render, dark theme active, responsive breakpoints work)
+5. For interactive components (forms, modals, auth), confirm basic interactions work
+
+The build verification (`pnpm build`) confirms all pages can be statically generated without errors.
+
+## Risk Mitigations
+
+- **Backup old code**: Keep old `web/` contents in a branch before starting
+- **Incremental porting**: Each file is ported and verified before moving to the next
+- **Vuetify MCP**: Use for every Vuetify component to catch breaking changes
+- **Restored breakpoints**: Keep v2/v3 breakpoint values to minimize layout drift
+- **CSS Reset compatibility**: Add selective reset CSS to maintain existing spacing behavior
diff --git a/tests/.env.test b/tests/.env.test
new file mode 100644
index 00000000..78bb614b
--- /dev/null
+++ b/tests/.env.test
@@ -0,0 +1,33 @@
+ENV=local
+GCP_PROJECT_ID=httpsms-test
+USE_HTTP_LOGGER=true
+ENTITLEMENT_ENABLED=false
+EVENTS_QUEUE_TYPE=emulator
+EVENTS_QUEUE_NAME=events-local
+EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events
+EVENTS_QUEUE_USER_API_KEY=system-user-api-key
+EVENTS_QUEUE_USER_ID=system-user-id
+FCM_ENDPOINT=http://wiremock:8080
+DATABASE_URL=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable
+DATABASE_URL_DEDICATED=postgresql://root@cockroachdb:26257/httpsms?sslmode=disable
+DATABASE_MIGRATION_CONSTRAINT_FIX=1
+REDIS_URL=redis://@redis:6379
+APP_PORT=8000
+APP_NAME=httpSMS
+APP_URL=http://localhost:8000
+SWAGGER_HOST=localhost:8000
+SMTP_FROM_NAME=httpSMS
+SMTP_FROM_EMAIL=test@httpsms.com
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_HOST=localhost
+SMTP_PORT=2525
+PUSHER_APP_ID=
+PUSHER_KEY=
+PUSHER_SECRET=
+PUSHER_CLUSTER=
+GCS_BUCKET_NAME=
+UPTRACE_DSN=
+CLOUDFLARE_TURNSTILE_SECRET_KEY=
+HEARTBEAT_DB_BACKEND=mongodb
+MONGODB_URI=mongodb://httpsms:testpassword@mongodb:27017/?authSource=admin&appName=httpsms
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..a37653e5
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,214 @@
+# Integration Tests
+
+End-to-end integration tests for the httpSMS API. These tests validate the complete SMS lifecycle by running the full application stack in Docker alongside a phone emulator service.
+
+## Architecture
+
+```
+┌──────────────┐ HTTP ┌──────────────┐
+│ Test Runner │─────────────▶│ API (Go) │
+│ (Go test) │ │ Port 8000 │
+└──────────────┘ └──────┬───────┘
+ │
+ FCM Push │ Events
+ (HTTP) │ (HTTP)
+ ▼
+ ┌──────────────┐
+ │ Emulator │
+ │ (Fiber v3) │
+ │ Port 9090 │
+ └──────────────┘
+ │
+ ┌──────┴───────┐
+ │ CockroachDB │ │ Redis │
+ │ Port 26257 │ │ Port 6379 │
+ └──────────────┘ └─────────────┘
+```
+
+### Components
+
+| Component | Description |
+| --------------- | -------------------------------------------------------- |
+| **API** | The httpSMS Go API server running in Docker |
+| **Emulator** | A Fiber v3 Go service that simulates an Android phone |
+| **CockroachDB** | Database for the API (single-node, insecure mode) |
+| **Redis** | Cache and queue backend |
+| **Seed** | One-shot container that seeds test data into CockroachDB |
+| **Test Runner** | Go test binary that runs on the host machine |
+
+### How It Works
+
+1. **Send SMS flow**: Test sends `POST /v1/messages/send` → API pushes FCM notification to emulator → Emulator calls `GET /v1/messages/outstanding` → Emulator fires `SENT` and `DELIVERED` events → Test polls `GET /v1/messages/{id}` until status is `delivered`
+
+2. **Receive SMS flow**: Test sends `POST /v1/messages/receive` (as the phone) → API stores message → Test verifies via `GET /v1/messages/{id}`
+
+### FCM Redirect
+
+The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect all FCM HTTP requests to the emulator instead of Google's servers. The emulator serves:
+
+- `/token` — Fake OAuth2 token endpoint (Firebase SDK requests tokens before sending)
+- `/v1/projects/:project/messages:send` — Fake FCM push endpoint
+
+## Test Coverage
+
+- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status
+- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) with Docker Compose
+- [Go 1.22+](https://go.dev/dl/)
+- [jq](https://jqlang.github.io/jq/download/) (for Firebase credentials generation)
+- [OpenSSL](https://www.openssl.org/) (for RSA key generation)
+
+## Running Locally
+
+### 1. Generate Firebase Credentials
+
+The integration tests use a fake Firebase service account. Generate it with:
+
+```bash
+cd tests
+bash generate-firebase-credentials.sh
+```
+
+This creates `firebase-credentials.json` with a throwaway RSA key (the emulator doesn't validate tokens).
+
+### 2. Set Environment Variable
+
+```bash
+export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json)
+```
+
+### 3. Start the Stack
+
+```bash
+docker compose up -d --build --wait
+```
+
+This starts CockroachDB, Redis, the API, and the emulator. The `--wait` flag blocks until all health checks pass.
+
+### 4. Wait for Seeding
+
+```bash
+docker compose wait seed
+sleep 2
+```
+
+The seed container inserts test users, phones, and API keys into CockroachDB after the API has run its GORM migrations.
+
+### 5. Run Tests
+
+```bash
+go test -v -timeout 120s ./...
+```
+
+### 6. Tear Down
+
+```bash
+docker compose down -v
+```
+
+The `-v` flag removes volumes (database data) for a clean slate next run.
+
+### One-Liner
+
+```bash
+cd tests && \
+ bash generate-firebase-credentials.sh && \
+ export FIREBASE_CREDENTIALS=$(jq -c . firebase-credentials.json) && \
+ docker compose up -d --build --wait && \
+ docker compose wait seed && \
+ sleep 2 && \
+ go test -v -timeout 120s ./... ; \
+ docker compose down -v
+```
+
+## CI/CD
+
+Integration tests run automatically via GitHub Actions (`.github/workflows/integration-test.yml`):
+
+- **Trigger**: Push to `main` or pull request targeting `main`
+- **Flow**: Generates credentials → Starts Docker stack → Seeds DB → Runs tests → Collects logs on failure → Tears down
+- **Gate**: Deployment should only proceed if integration tests pass
+
+## Test Data
+
+| Entity | Value |
+| -------------- | -------------------------------------- |
+| User API Key | `test-user-api-key` |
+| Phone API Key | `pk_test-phone-api-key` |
+| Phone Number | `+18005550199` |
+| Contact Number | `+18005550100` |
+| User ID | `test-user-id` |
+| Phone ID | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` |
+
+See [`seed.sql`](./seed.sql) for the complete seed data.
+
+## Project Structure
+
+```
+tests/
+├── docker-compose.yml # Full stack orchestration
+├── seed.sql # Database seed data
+├── .env.test # API environment variables
+├── generate-firebase-credentials.sh # Generates fake Firebase credentials
+├── go.mod # Test runner Go module
+├── go.sum
+├── helpers_test.go # Test utilities (HTTP client, polling)
+├── integration_test.go # E2E test cases
+└── emulator/ # Phone emulator service
+ ├── Dockerfile
+ ├── go.mod
+ ├── go.sum
+ ├── main.go # Fiber v3 entry point
+ ├── emulator.go # Emulator struct and config
+ ├── token_handler.go # Fake OAuth2 token endpoint
+ ├── fcm_handler.go # Fake FCM push receiver
+ └── events.go # Event firing logic (SENT/DELIVERED)
+```
+
+## Troubleshooting
+
+### API fails to start
+
+Check the API logs:
+
+```bash
+docker compose logs api
+```
+
+Common issues:
+
+- `FIREBASE_CREDENTIALS` env var not set or malformed
+- CockroachDB not ready (increase `start_period` in healthcheck)
+
+### Tests timeout waiting for `delivered` status
+
+Check the emulator logs:
+
+```bash
+docker compose logs emulator
+```
+
+The emulator should show:
+
+1. `[FCM]` — Receiving the push notification
+2. `[EVENTS]` — Fetching outstanding messages and firing events
+
+If no `[FCM]` entries appear, the API isn't reaching the emulator (check `FCM_ENDPOINT` in `.env.test`).
+
+### Seed container fails
+
+```bash
+docker compose logs seed
+```
+
+If you see "relation does not exist" errors, the API hasn't finished GORM migrations yet. Increase the API's `start_period` in `docker-compose.yml`.
+
+## Adding New Tests
+
+1. Add test functions to `integration_test.go` (or create new `*_test.go` files)
+2. Use `doRequest()` helper for authenticated HTTP calls
+3. Use `pollMessageStatus()` to wait for async state changes
+4. Update the test coverage checklist in this README
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
new file mode 100644
index 00000000..3d82d47c
--- /dev/null
+++ b/tests/docker-compose.yml
@@ -0,0 +1,120 @@
+services:
+ cockroachdb:
+ image: cockroachdb/cockroach:latest
+ command: start-single-node --insecure --store=type=mem,size=640MiB
+ ports:
+ - "26257:26257"
+ - "8081:8080"
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "cockroach",
+ "sql",
+ "--insecure",
+ "--host=localhost",
+ "--execute=SELECT 1;",
+ ]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+
+ cockroachdb-init:
+ image: cockroachdb/cockroach:latest
+ depends_on:
+ cockroachdb:
+ condition: service_healthy
+ entrypoint:
+ [
+ "cockroach",
+ "sql",
+ "--insecure",
+ "--host=cockroachdb",
+ "--execute=CREATE DATABASE IF NOT EXISTS httpsms;",
+ ]
+ restart: "no"
+
+ redis:
+ image: redis:latest
+ command: redis-server
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ mongodb:
+ image: mongo:7
+ ports:
+ - "27017:27017"
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: httpsms
+ MONGO_INITDB_ROOT_PASSWORD: testpassword
+ healthcheck:
+ test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+ wiremock:
+ image: wiremock/wiremock:3x
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./wiremock/mappings:/home/wiremock/mappings:ro
+ networks:
+ default:
+ aliases:
+ - wiremock.local
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ api:
+ build:
+ context: ../api
+ ports:
+ - "8000:8000"
+ depends_on:
+ cockroachdb-init:
+ condition: service_completed_successfully
+ redis:
+ condition: service_healthy
+ wiremock:
+ condition: service_healthy
+ mongodb:
+ condition: service_healthy
+ env_file:
+ - .env.test
+ environment:
+ FIREBASE_CREDENTIALS: "${FIREBASE_CREDENTIALS}"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 5s
+ timeout: 10s
+ retries: 20
+ start_period: 30s
+
+ seed:
+ image: cockroachdb/cockroach:latest
+ depends_on:
+ api:
+ condition: service_healthy
+ volumes:
+ - ./seed.sql:/seed.sql:ro
+ entrypoint:
+ [
+ "cockroach",
+ "sql",
+ "--insecure",
+ "--host=cockroachdb",
+ "--database=httpsms",
+ "--file=/seed.sql",
+ ]
+ restart: "no"
diff --git a/tests/generate-firebase-credentials.sh b/tests/generate-firebase-credentials.sh
new file mode 100644
index 00000000..70f47cd8
--- /dev/null
+++ b/tests/generate-firebase-credentials.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Generates a fake Firebase service account JSON for integration tests.
+# The RSA key is throwaway — it only needs to be valid so the Firebase SDK can sign JWTs.
+# WireMock does not validate these tokens.
+
+set -e
+
+OUTFILE="${1:-firebase-credentials.json}"
+
+# Generate a 2048-bit RSA key
+PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null)
+
+# Escape newlines for JSON embedding
+PRIVATE_KEY_ESCAPED=$(echo "$PRIVATE_KEY" | awk '{printf "%s\\n", $0}')
+
+cat > "$OUTFILE" <= expectedCount {
+ return requests
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ requests := findWebhookRequests(t, webhookPath)
+ require.GreaterOrEqual(t, len(requests), expectedCount, "expected at least %d webhook events on %s, got %d", expectedCount, webhookPath, len(requests))
+ return requests
+}
+
+func waitForFCMPush(t *testing.T, messageID string, timeout time.Duration) []wmJournal.GetRequestResponse {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+
+ for time.Now().Before(deadline) {
+ requests := findFCMRequests(t, messageID)
+ if len(requests) >= 1 {
+ return requests
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ t.Fatalf("FCM push for message %s not found within %v", messageID, timeout)
+ return nil
+}
+
+type BulkMessageEntry struct {
+ RequestID string `json:"request_id"`
+ Total int `json:"total"`
+ ScheduledCount int `json:"scheduled_count"`
+ PendingCount int `json:"pending_count"`
+ FailedCount int `json:"failed_count"`
+ ExpiredCount int `json:"expired_count"`
+ SentCount int `json:"sent_count"`
+ DeliveredCount int `json:"delivered_count"`
+ CreatedAt string `json:"created_at"`
+}
+
+func uploadBulkFile(ctx context.Context, t *testing.T, filename string, fileBytes []byte) (int, []byte) {
+ t.Helper()
+
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+
+ part, err := writer.CreateFormFile("document", filename)
+ require.NoError(t, err)
+
+ _, err = part.Write(fileBytes)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ url := apiBaseURL + "/v1/bulk-messages"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ req.Header.Set("x-api-key", userAPIKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ return resp.StatusCode, body
+}
+
+func fetchBulkMessages(ctx context.Context, t *testing.T) []BulkMessageEntry {
+ t.Helper()
+
+ url := apiBaseURL + "/v1/bulk-messages"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", userAPIKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode, "fetch bulk messages failed: %s", string(body))
+
+ var result struct {
+ Data []BulkMessageEntry `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(body, &result))
+ return result.Data
+}
+
+func searchMessages(ctx context.Context, t *testing.T, contact string, owner string) []httpsms.Message {
+ t.Helper()
+
+ url := fmt.Sprintf("%s/v1/messages?contact=%s&owner=%s&limit=20&skip=0", apiBaseURL, contact, owner)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", userAPIKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode, "search messages failed: %s", string(body))
+
+ var result struct {
+ Data []httpsms.Message `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(body, &result))
+ return result.Data
+}
+
+func findBulkEntry(entries []BulkMessageEntry, requestID string) *BulkMessageEntry {
+ for i := range entries {
+ if entries[i].RequestID == requestID {
+ return &entries[i]
+ }
+ }
+ return nil
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
new file mode 100644
index 00000000..66abecdd
--- /dev/null
+++ b/tests/integration_test.go
@@ -0,0 +1,564 @@
+package tests
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ httpsms "github.com/NdoleStudio/httpsms-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/xuri/excelize/v2"
+)
+
+func TestSendSMS_Encrypted(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ encryptionKey := randomEncryptionKey()
+ signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{
+ "message.phone.sent",
+ "message.phone.delivered",
+ })
+
+ client := newAPIClient()
+ plaintext := "Hello encrypted world " + randomEncryptionKey()
+ ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext)
+ require.NoError(t, err)
+ require.NotEqual(t, plaintext, ciphertext)
+
+ contactNumber := randomPhoneNumber()
+ sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{
+ From: phone.PhoneNumber,
+ To: contactNumber,
+ Content: ciphertext,
+ Encrypted: true,
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode)
+
+ messageID := sendResp.Data.ID.String()
+ require.NotEmpty(t, messageID)
+ t.Logf("sent encrypted message: %s", messageID)
+
+ fcmRequests := waitForFCMPush(t, messageID, 30*time.Second)
+ require.Len(t, fcmRequests, 1)
+
+ outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID)
+ assert.Equal(t, true, outstanding["encrypted"])
+ assert.Equal(t, ciphertext, outstanding["content"])
+ assert.NotEqual(t, plaintext, outstanding["content"])
+
+ fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT")
+ time.Sleep(200 * time.Millisecond)
+ fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED")
+
+ msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second)
+ assert.Equal(t, "delivered", msg.Status)
+ assert.True(t, msg.Encrypted)
+ assert.Equal(t, ciphertext, msg.Content)
+
+ decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted)
+
+ webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second)
+ for _, req := range webhookReqs {
+ assertWebhookJWT(t, req.Request, signingKey)
+ }
+
+ var eventTypes []string
+ for _, req := range webhookReqs {
+ if et, ok := req.Request.Headers["X-Event-Type"]; ok {
+ eventTypes = append(eventTypes, et)
+ } else if et, ok := req.Request.Headers["x-event-type"]; ok {
+ eventTypes = append(eventTypes, et)
+ }
+ }
+ assert.Contains(t, eventTypes, "message.phone.sent")
+ assert.Contains(t, eventTypes, "message.phone.delivered")
+}
+
+func TestReceiveSMS_Encrypted(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ encryptionKey := randomEncryptionKey()
+ signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{
+ "message.phone.received",
+ })
+
+ client := newAPIClient()
+ plaintext := "Incoming secret message " + randomEncryptionKey()
+ ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext)
+ require.NoError(t, err)
+
+ contactNumber := randomPhoneNumber()
+ receivePayload := map[string]interface{}{
+ "from": contactNumber,
+ "to": phone.PhoneNumber,
+ "content": ciphertext,
+ "encrypted": true,
+ "sim": "SIM1",
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ }
+ body, err := json.Marshal(receivePayload)
+ require.NoError(t, err)
+
+ url := apiBaseURL + "/v1/messages/receive"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-api-key", phone.PhoneAPIKey)
+
+ httpResp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer httpResp.Body.Close()
+
+ respBody, err := io.ReadAll(httpResp.Body)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, httpResp.StatusCode, "receive response: %s", string(respBody))
+
+ var receiveResult httpsms.MessageResponse
+ require.NoError(t, json.Unmarshal(respBody, &receiveResult))
+ messageID := receiveResult.Data.ID.String()
+ require.NotEmpty(t, messageID)
+ t.Logf("received encrypted message: %s", messageID)
+
+ msg := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second)
+ assert.Equal(t, "received", msg.Status)
+ assert.True(t, msg.Encrypted)
+ assert.Equal(t, ciphertext, msg.Content)
+ assert.NotEqual(t, plaintext, msg.Content)
+
+ decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted)
+
+ webhookReqs := waitForWebhookEvents(t, webhookPath, 1, 30*time.Second)
+ require.GreaterOrEqual(t, len(webhookReqs), 1)
+ assertWebhookJWT(t, webhookReqs[0].Request, signingKey)
+
+ eventType := webhookReqs[0].Request.Headers["X-Event-Type"]
+ if eventType == "" {
+ eventType = webhookReqs[0].Request.Headers["x-event-type"]
+ }
+ assert.Equal(t, "message.phone.received", eventType)
+}
+
+func TestSendSMS_RateLimit(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 10)
+
+ signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{
+ "message.phone.sent",
+ "message.phone.delivered",
+ })
+
+ client := newAPIClient()
+ contactNumber := randomPhoneNumber()
+
+ sendResp1, resp1, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{
+ From: phone.PhoneNumber,
+ To: contactNumber,
+ Content: "Rate limit test message 1",
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp1.HTTPResponse.StatusCode)
+ msgID1 := sendResp1.Data.ID.String()
+
+ sendResp2, resp2, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{
+ From: phone.PhoneNumber,
+ To: contactNumber,
+ Content: "Rate limit test message 2",
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp2.HTTPResponse.StatusCode)
+ msgID2 := sendResp2.Data.ID.String()
+
+ t.Logf("sent messages: %s, %s", msgID1, msgID2)
+
+ fcm1 := waitForFCMPush(t, msgID1, 30*time.Second)
+ require.Len(t, fcm1, 1)
+
+ fcm2 := waitForFCMPush(t, msgID2, 30*time.Second)
+ require.Len(t, fcm2, 1)
+
+ time1 := fcm1[0].Request.LoggedDate
+ time2 := fcm2[0].Request.LoggedDate
+ gapMs := time2 - time1
+ if gapMs < 0 {
+ gapMs = time1 - time2
+ }
+ t.Logf("FCM push gap: %dms", gapMs)
+ assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs)
+
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT")
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED")
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT")
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED")
+
+ msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second)
+ msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second)
+ assert.Equal(t, "delivered", msg1.Status)
+ assert.Equal(t, "delivered", msg2.Status)
+
+ webhookReqs := waitForWebhookEvents(t, webhookPath, 4, 30*time.Second)
+ for _, req := range webhookReqs {
+ assertWebhookJWT(t, req.Request, signingKey)
+ }
+}
+
+func TestRotateAPIKey_InvalidatesCache(t *testing.T) {
+ ctx := context.Background()
+
+ // Use a dedicated test user so we don't mutate the shared userAPIKey
+ rotateUserAPIKey := "rotate-test-api-key"
+ rotateUserID := "rotate-test-user-id"
+
+ // 1) Confirm the dedicated user's API key works and warm the cache
+ meURL := apiBaseURL + "/v1/users/me"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", rotateUserAPIKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode, "initial auth failed: %s", string(body))
+
+ // Parse the current API key from the response
+ var meResp struct {
+ Data struct {
+ ID string `json:"id"`
+ APIKey string `json:"api_key"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(body, &meResp))
+ require.Equal(t, rotateUserID, meResp.Data.ID)
+ oldAPIKey := meResp.Data.APIKey
+ require.NotEmpty(t, oldAPIKey)
+ t.Logf("user ID: %s, old API key prefix: %s...", rotateUserID, oldAPIKey[:10])
+
+ // 2) Rotate the API key
+ rotateURL := fmt.Sprintf("%s/v1/users/%s/api-keys", apiBaseURL, rotateUserID)
+ req, err = http.NewRequestWithContext(ctx, http.MethodDelete, rotateURL, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", rotateUserAPIKey)
+
+ resp, err = http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode, "rotate failed: %s", string(body))
+
+ // Parse new API key from rotate response
+ var rotateResp struct {
+ Data struct {
+ APIKey string `json:"api_key"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(body, &rotateResp))
+ newAPIKey := rotateResp.Data.APIKey
+ require.NotEmpty(t, newAPIKey)
+ require.NotEqual(t, oldAPIKey, newAPIKey, "API key should have changed after rotation")
+ t.Logf("new API key prefix: %s...", newAPIKey[:10])
+
+ // 3) Old API key should immediately fail (401) — this is the bug regression check
+ req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", oldAPIKey)
+
+ resp, err = http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "old API key should return 401 after rotation")
+
+ // 4) New API key should work
+ req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
+ require.NoError(t, err)
+ req.Header.Set("x-api-key", newAPIKey)
+
+ resp, err = http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "new API key should work: %s", string(body))
+}
+
+func TestSendSMS_OutstandingFlow(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{
+ "message.phone.sent",
+ "message.phone.delivered",
+ })
+
+ client := newAPIClient()
+ contactNumber := randomPhoneNumber()
+ content := "Outstanding flow test " + randomEncryptionKey()
+
+ sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{
+ From: phone.PhoneNumber,
+ To: contactNumber,
+ Content: content,
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode)
+
+ messageID := sendResp.Data.ID.String()
+ t.Logf("sent message: %s", messageID)
+
+ fcmReqs := waitForFCMPush(t, messageID, 30*time.Second)
+ require.Len(t, fcmReqs, 1)
+ assert.Contains(t, fcmReqs[0].Request.Body, messageID)
+ assert.True(t, strings.Contains(fcmReqs[0].Request.URL, "/messages:send") || strings.Contains(fcmReqs[0].Request.AbsoluteURL, "/messages:send"))
+
+ outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID)
+ assert.Equal(t, messageID, outstanding["id"])
+ assert.Equal(t, content, outstanding["content"])
+ assert.Equal(t, phone.PhoneNumber, outstanding["owner"])
+ assert.Equal(t, contactNumber, outstanding["contact"])
+
+ fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT")
+ time.Sleep(200 * time.Millisecond)
+ fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED")
+
+ msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second)
+ assert.Equal(t, "delivered", msg.Status)
+ assert.Equal(t, content, msg.Content)
+
+ webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second)
+ for _, req := range webhookReqs {
+ assertWebhookJWT(t, req.Request, signingKey)
+ }
+}
+
+func TestHeartbeat_StoreAndIndex(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ // Store a heartbeat via phone API key (retry to allow async phone-API-key association)
+ storePayload := map[string]interface{}{
+ "phone_numbers": []string{phone.PhoneNumber},
+ "charging": true,
+ }
+
+ url := apiBaseURL + "/v1/heartbeats"
+ var respBody []byte
+ var statusCode int
+ deadline := time.Now().Add(15 * time.Second)
+ for time.Now().Before(deadline) {
+ body, err := json.Marshal(storePayload)
+ require.NoError(t, err)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-api-key", phone.PhoneAPIKey)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+
+ respBody, err = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ require.NoError(t, err)
+
+ statusCode = resp.StatusCode
+ if statusCode == http.StatusCreated {
+ break
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+ require.Equal(t, http.StatusCreated, statusCode, "store heartbeat failed: %s", string(respBody))
+
+ // Read heartbeats back via user API key
+ client := newAPIClient()
+ heartbeats, indexResp, err := client.Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{
+ Owner: phone.PhoneNumber,
+ Limit: 1,
+ })
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, indexResp.HTTPResponse.StatusCode)
+
+ require.NotNil(t, heartbeats)
+ require.GreaterOrEqual(t, len(heartbeats.Data), 1, "expected at least 1 heartbeat")
+
+ hb := heartbeats.Data[0]
+ assert.Equal(t, phone.PhoneNumber, hb.Owner)
+ assert.True(t, hb.Charging)
+ assert.False(t, hb.Timestamp.IsZero(), "timestamp should not be zero")
+}
+
+func TestBulkSMS_CSV(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ // Build CSV content with 1 message
+ contact := randomPhoneNumber()
+ csvContent := fmt.Sprintf("FromPhoneNumber,ToPhoneNumber,Content,SendTime(optional)\n%s,%s,CSV bulk test message,\n",
+ phone.PhoneNumber, contact)
+
+ // Upload CSV
+ statusCode, respBody := uploadBulkFile(ctx, t, "test.csv", []byte(csvContent))
+ require.Equal(t, http.StatusAccepted, statusCode, "upload failed: %s", string(respBody))
+ t.Logf("upload response: %s", string(respBody))
+
+ // Parse the response to verify message count
+ var uploadResp struct {
+ Message string `json:"message"`
+ }
+ require.NoError(t, json.Unmarshal(respBody, &uploadResp))
+ assert.Contains(t, uploadResp.Message, "1 out of 1")
+
+ // Wait a moment for messages to be persisted
+ time.Sleep(2 * time.Second)
+
+ // Search for the bulk message by owner to get message IDs
+ messages := searchMessages(ctx, t, contact, phone.PhoneNumber)
+ require.GreaterOrEqual(t, len(messages), 1, "expected at least 1 message for phone %s", phone.PhoneNumber)
+
+ // Find the message with bulk- request_id prefix
+ var bulkMsg *httpsms.Message
+ for i := range messages {
+ if messages[i].RequestID != nil && strings.HasPrefix(*messages[i].RequestID, "bulk-") {
+ bulkMsg = &messages[i]
+ break
+ }
+ }
+ require.NotNil(t, bulkMsg, "no message with bulk- request_id found")
+ messageID := bulkMsg.ID.String()
+ requestID := *bulkMsg.RequestID
+ t.Logf("found bulk message: id=%s, request_id=%s", messageID, requestID)
+
+ // Wait for FCM push
+ waitForFCMPush(t, messageID, 30*time.Second)
+
+ // Fire SENT event
+ fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT")
+
+ // Poll until message reaches "sent" status
+ msg := pollMessageStatus(ctx, t, messageID, "sent", 15*time.Second)
+ assert.Equal(t, "sent", msg.Status)
+
+ // Verify bulk-messages history endpoint
+ entries := fetchBulkMessages(ctx, t)
+ entry := findBulkEntry(entries, requestID)
+ require.NotNil(t, entry, "bulk entry with request_id %s not found in history", requestID)
+
+ assert.Equal(t, 1, entry.Total)
+ assert.Equal(t, 1, entry.SentCount)
+ assert.Equal(t, 0, entry.PendingCount)
+ assert.Equal(t, 0, entry.FailedCount)
+ assert.Equal(t, 0, entry.ExpiredCount)
+ assert.Equal(t, 0, entry.DeliveredCount)
+ assert.Equal(t, 0, entry.ScheduledCount)
+}
+
+func TestBulkSMS_Excel(t *testing.T) {
+ ctx := context.Background()
+ phone := setupPhone(ctx, t, 60)
+
+ contact1 := randomPhoneNumber()
+ contact2 := randomPhoneNumber()
+
+ // Build Excel file with 2 messages
+ f := excelize.NewFile()
+ sheet := f.GetSheetName(0)
+ f.SetCellValue(sheet, "A1", "FromPhoneNumber")
+ f.SetCellValue(sheet, "B1", "ToPhoneNumber")
+ f.SetCellValue(sheet, "C1", "Content")
+ f.SetCellValue(sheet, "D1", "SendTime(optional)")
+
+ f.SetCellValue(sheet, "A2", phone.PhoneNumber)
+ f.SetCellValue(sheet, "B2", contact1)
+ f.SetCellValue(sheet, "C2", "Excel bulk test message 1")
+ f.SetCellValue(sheet, "D2", "")
+
+ f.SetCellValue(sheet, "A3", phone.PhoneNumber)
+ f.SetCellValue(sheet, "B3", contact2)
+ f.SetCellValue(sheet, "C3", "Excel bulk test message 2")
+ f.SetCellValue(sheet, "D3", "")
+
+ var excelBuf bytes.Buffer
+ require.NoError(t, f.Write(&excelBuf))
+
+ // Upload Excel
+ statusCode, respBody := uploadBulkFile(ctx, t, "test.xlsx", excelBuf.Bytes())
+ require.Equal(t, http.StatusAccepted, statusCode, "upload failed: %s", string(respBody))
+ t.Logf("upload response: %s", string(respBody))
+
+ var uploadResp struct {
+ Message string `json:"message"`
+ }
+ require.NoError(t, json.Unmarshal(respBody, &uploadResp))
+ assert.Contains(t, uploadResp.Message, "2 out of 2")
+
+ // Wait for messages to be persisted
+ time.Sleep(2 * time.Second)
+
+ // Search for bulk messages by owner and each contact
+ messages1 := searchMessages(ctx, t, contact1, phone.PhoneNumber)
+ messages2 := searchMessages(ctx, t, contact2, phone.PhoneNumber)
+ messages := append(messages1, messages2...)
+ require.GreaterOrEqual(t, len(messages), 2, "expected at least 2 messages for phone %s", phone.PhoneNumber)
+
+ // Find messages with bulk- request_id prefix
+ var bulkMessages []httpsms.Message
+ var requestID string
+ for i := range messages {
+ if messages[i].RequestID != nil && strings.HasPrefix(*messages[i].RequestID, "bulk-") {
+ bulkMessages = append(bulkMessages, messages[i])
+ requestID = *messages[i].RequestID
+ }
+ }
+ require.Len(t, bulkMessages, 2, "expected 2 messages with bulk- request_id")
+ require.NotEmpty(t, requestID)
+ t.Logf("found %d bulk messages with request_id=%s", len(bulkMessages), requestID)
+
+ // Wait for FCM pushes for both messages
+ msgID1 := bulkMessages[0].ID.String()
+ msgID2 := bulkMessages[1].ID.String()
+ waitForFCMPush(t, msgID1, 30*time.Second)
+ waitForFCMPush(t, msgID2, 30*time.Second)
+
+ // Fire SENT then DELIVERED on message 1, leave message 2 pending
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT")
+ time.Sleep(200 * time.Millisecond)
+ fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED")
+
+ // Poll until message 1 reaches "delivered"
+ msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second)
+ assert.Equal(t, "delivered", msg1.Status)
+
+ // Poll until message 2 reaches "scheduled" (FCM push sent but no SENT event fired)
+ msg2 := pollMessageStatus(ctx, t, msgID2, "scheduled", 15*time.Second)
+ assert.Equal(t, "scheduled", msg2.Status)
+
+ // Verify bulk-messages history endpoint
+ entries := fetchBulkMessages(ctx, t)
+ entry := findBulkEntry(entries, requestID)
+ require.NotNil(t, entry, "bulk entry with request_id %s not found in history", requestID)
+
+ assert.Equal(t, 2, entry.Total)
+ assert.Equal(t, 1, entry.DeliveredCount)
+ assert.Equal(t, 0, entry.PendingCount)
+ assert.Equal(t, 0, entry.SentCount)
+ assert.Equal(t, 0, entry.FailedCount)
+ assert.Equal(t, 0, entry.ExpiredCount)
+ assert.Equal(t, 1, entry.ScheduledCount)
+}
diff --git a/tests/seed.sql b/tests/seed.sql
new file mode 100644
index 00000000..36d714d9
--- /dev/null
+++ b/tests/seed.sql
@@ -0,0 +1,38 @@
+-- Seed test data for integration tests
+-- Run AFTER GORM has migrated the schema (i.e., after API starts)
+
+-- Test user
+INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at)
+VALUES (
+ 'test-user-id',
+ 'test@httpsms.com',
+ 'test-user-api-key',
+ 'UTC',
+ 'pro-monthly',
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- Test user for API key rotation tests (isolated to avoid mutating the shared test user)
+INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at)
+VALUES (
+ 'rotate-test-user-id',
+ 'rotate-test@httpsms.com',
+ 'rotate-test-api-key',
+ 'UTC',
+ 'pro-monthly',
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
+
+-- System user (for event queue auth)
+INSERT INTO users (id, email, api_key, timezone, subscription_name, created_at, updated_at)
+VALUES (
+ 'system-user-id',
+ 'system@httpsms.com',
+ 'system-user-api-key',
+ 'UTC',
+ 'pro-monthly',
+ NOW(),
+ NOW()
+) ON CONFLICT (id) DO NOTHING;
diff --git a/tests/wiremock/mappings/fcm-send.json b/tests/wiremock/mappings/fcm-send.json
new file mode 100644
index 00000000..7640fb7c
--- /dev/null
+++ b/tests/wiremock/mappings/fcm-send.json
@@ -0,0 +1,15 @@
+{
+ "request": {
+ "urlPathPattern": "/v1/projects/.*/messages:send",
+ "method": "POST"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "name": "projects/httpsms-test/messages/fake-message-id"
+ }
+ }
+}
diff --git a/tests/wiremock/mappings/oauth-token.json b/tests/wiremock/mappings/oauth-token.json
new file mode 100644
index 00000000..9518f4fe
--- /dev/null
+++ b/tests/wiremock/mappings/oauth-token.json
@@ -0,0 +1,17 @@
+{
+ "request": {
+ "urlPathPattern": "/token",
+ "method": "POST"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "access_token": "fake-access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600
+ }
+ }
+}
diff --git a/tests/wiremock/mappings/webhook-receiver.json b/tests/wiremock/mappings/webhook-receiver.json
new file mode 100644
index 00000000..79966b64
--- /dev/null
+++ b/tests/wiremock/mappings/webhook-receiver.json
@@ -0,0 +1,15 @@
+{
+ "request": {
+ "urlPathPattern": "/webhooks/.*",
+ "method": "POST"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "jsonBody": {
+ "status": "received"
+ }
+ }
+}
diff --git a/web/.env.docker b/web/.env.docker
index d48c328f..b1751dfb 100644
--- a/web/.env.docker
+++ b/web/.env.docker
@@ -15,3 +15,7 @@ FIREBASE_STORAGE_BUCKET=httpsms-docker.appspot.com
FIREBASE_MESSAGING_SENDER_ID=668063041624
FIREBASE_APP_ID=668063041624:web:29b9e3b7027965ba08a22d
FIREBASE_MEASUREMENT_ID=G-18VRYL22PZ
+
+# Cloudflare Turnstile site key for captcha on the search messages page
+# Get your site key at https://developers.cloudflare.com/turnstile/get-started/
+CLOUDFLARE_TURNSTILE_SITE_KEY=
diff --git a/web/Dockerfile b/web/Dockerfile
index 169f78cc..813399ea 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,5 +1,5 @@
# build stage
-FROM node:lts-alpine as build
+FROM node:lts-alpine AS build
WORKDIR /app
diff --git a/web/components/MessageThread.vue b/web/components/MessageThread.vue
index 0f0f222b..51676b85 100644
--- a/web/components/MessageThread.vue
+++ b/web/components/MessageThread.vue
@@ -95,8 +95,10 @@
{{ thread.contact | phoneNumber }}
-
- {{ thread.last_message_content }}
+
+
+ {{ thread.last_message_content }}
+
@@ -150,6 +152,7 @@ import {
mdiCheck,
mdiAlert,
mdiAccount,
+ mdiPaperclip,
} from '@mdi/js'
@Component
@@ -160,6 +163,7 @@ export default class MessageThread extends Vue {
mdiAlert = mdiAlert
mdiCheck = mdiCheck
mdiCheckAll = mdiCheckAll
+ mdiPaperclip = mdiPaperclip
get threads(): Array {
return this.$store.getters.getThreads
diff --git a/web/layouts/default.vue b/web/layouts/default.vue
index 1c2049e5..cff2bd34 100644
--- a/web/layouts/default.vue
+++ b/web/layouts/default.vue
@@ -73,7 +73,7 @@ export default class DefaultLayout extends Vue {
if (this.$store.getters.getAuthUser && this.$store.getters.getOwner) {
setAuthHeader((await this.$fire.auth.currentUser?.getIdToken()) ?? '')
promises.push(
- promises.push(this.$store.dispatch('loadPhones', true)),
+ this.$store.dispatch('loadPhones', true),
this.$store.dispatch('loadThreads'),
this.$store.dispatch('getHeartbeat'),
)
diff --git a/web/models/api.ts b/web/models/api.ts
index 7b2d542f..c4d7b8dc 100644
--- a/web/models/api.ts
+++ b/web/models/api.ts
@@ -10,6 +10,25 @@
* ---------------------------------------------------------------
*/
+export enum EntitiesSubscriptionName {
+ SubscriptionNameFree = 'free',
+ SubscriptionNameProMonthly = 'pro-monthly',
+ SubscriptionNameProYearly = 'pro-yearly',
+ SubscriptionNameUltraMonthly = 'ultra-monthly',
+ SubscriptionNameUltraYearly = 'ultra-yearly',
+ SubscriptionNameProLifetime = 'pro-lifetime',
+ SubscriptionName20KMonthly = '20k-monthly',
+ SubscriptionName100KMonthly = '100k-monthly',
+ SubscriptionName50KMonthly = '50k-monthly',
+ SubscriptionName200KMonthly = '200k-monthly',
+ SubscriptionName20KYearly = '20k-yearly',
+}
+
+export enum EntitiesSIM {
+ SIM1 = 'SIM1',
+ SIM2 = 'SIM2',
+}
+
export interface EntitiesBillingUsage {
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
@@ -31,6 +50,25 @@ export interface EntitiesBillingUsage {
user_id: string
}
+export interface EntitiesBulkMessage {
+ /** @example "2022-06-05T14:26:02.302718+03:00" */
+ created_at: string
+ /** @example 25 */
+ delivered_count: number
+ /** @example 5 */
+ failed_count: number
+ /** @example 30 */
+ pending_count: number
+ /** @example "bulk-32343a19-da5e-4b1b-a767-3298a73703cb" */
+ request_id: string
+ /** @example 50 */
+ scheduled_count: number
+ /** @example 40 */
+ sent_count: number
+ /** @example 150 */
+ total: number
+}
+
export interface EntitiesDiscord {
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
@@ -64,8 +102,8 @@ export interface EntitiesHeartbeat {
}
export interface EntitiesMessage {
- /** @example false */
- can_be_polled: boolean
+ /** @example ["https://example.com/image.jpg","https://example.com/video.mp4"] */
+ attachments: string[]
/** @example "+18005550100" */
contact: string
/** @example "This is a sample text message" */
@@ -73,19 +111,19 @@ export interface EntitiesMessage {
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- delivered_at: string
+ delivered_at?: string
/** @example false */
encrypted: boolean
/** @example "2022-06-05T14:26:09.527976+03:00" */
- expired_at: string
+ expired_at?: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- failed_at: string
+ failed_at?: string
/** @example "UNKNOWN" */
- failure_reason: string
+ failure_reason?: string
/** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
id: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- last_attempted_at: string
+ last_attempted_at?: string
/** @example 1 */
max_send_attempts: number
/** @example "2022-06-05T14:26:09.527976+03:00" */
@@ -93,24 +131,24 @@ export interface EntitiesMessage {
/** @example "+18005550199" */
owner: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- received_at: string
+ received_at?: string
/** @example "153554b5-ae44-44a0-8f4f-7bbac5657ad4" */
- request_id: string
+ request_id?: string
/** @example "2022-06-05T14:26:01.520828+03:00" */
request_received_at: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- scheduled_at: string
+ scheduled_at?: string
/** @example "2022-06-05T14:26:09.527976+03:00" */
- scheduled_send_time: string
+ scheduled_send_time?: string
/** @example 0 */
send_attempt_count: number
/**
* SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message
* @example 133414
*/
- send_time: number
+ send_time?: number
/** @example "2022-06-05T14:26:09.527976+03:00" */
- sent_at: string
+ sent_at?: string
/**
* SIM is the SIM card to use to send the message
* * SMS1: use the SIM card in slot 1
@@ -118,7 +156,7 @@ export interface EntitiesMessage {
* * DEFAULT: used the default communication SIM card
* @example "DEFAULT"
*/
- sim: string
+ sim: EntitiesSIM
/** @example "pending" */
status: string
/** @example "mobile-terminated" */
@@ -129,6 +167,31 @@ export interface EntitiesMessage {
user_id: string
}
+export interface EntitiesMessageSendSchedule {
+ /** @example "2022-06-05T14:26:02.302718+03:00" */
+ created_at: string
+ /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
+ id: string
+ /** @example "Business Hours" */
+ name: string
+ /** @example "Europe/Tallinn" */
+ timezone: string
+ /** @example "2022-06-05T14:26:10.303278+03:00" */
+ updated_at: string
+ /** @example "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" */
+ user_id: string
+ windows: EntitiesMessageSendScheduleWindow[]
+}
+
+export interface EntitiesMessageSendScheduleWindow {
+ /** @example 1 */
+ day_of_week: number
+ /** @example 1020 */
+ end_minute: number
+ /** @example 540 */
+ start_minute: number
+}
+
export interface EntitiesMessageThread {
/** @example "indigo" */
color: string
@@ -160,7 +223,7 @@ export interface EntitiesPhone {
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
/** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */
- fcm_token: string
+ fcm_token?: string
/** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
id: string
/**
@@ -170,14 +233,15 @@ export interface EntitiesPhone {
max_send_attempts: number
/** MessageExpirationSeconds is the duration in seconds after sending a message when it is considered to be expired. */
message_expiration_seconds: number
+ /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
+ message_send_schedule_id?: string
/** @example 1 */
messages_per_minute: number
/** @example "This phone cannot receive calls. Please send an SMS instead." */
- missed_call_auto_reply: string
+ missed_call_auto_reply?: string
/** @example "+18005550199" */
phone_number: string
- /** SIM card that received the message */
- sim: string
+ sim: EntitiesSIM
/** @example "2022-06-05T14:26:10.303278+03:00" */
updated_at: string
/** @example "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" */
@@ -185,7 +249,7 @@ export interface EntitiesPhone {
}
export interface EntitiesPhoneAPIKey {
- /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */
+ /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" */
api_key: string
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
@@ -193,9 +257,9 @@ export interface EntitiesPhoneAPIKey {
id: string
/** @example "Business Phone Key" */
name: string
- /** @example ["[32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc]"] */
+ /** @example ["32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc"] */
phone_ids: string[]
- /** @example ["[+18005550199","+18005550100]"] */
+ /** @example ["+18005550199","+18005550100"] */
phone_numbers: string[]
/** @example "2022-06-05T14:26:02.302718+03:00" */
updated_at: string
@@ -207,7 +271,7 @@ export interface EntitiesPhoneAPIKey {
export interface EntitiesUser {
/** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
- active_phone_id: string
+ active_phone_id?: string
/** @example "x-api-key" */
api_key: string
/** @example "2022-06-05T14:26:02.302718+03:00" */
@@ -225,15 +289,15 @@ export interface EntitiesUser {
/** @example true */
notification_webhook_enabled: boolean
/** @example "2022-06-05T14:26:02.302718+03:00" */
- subscription_ends_at: string
+ subscription_ends_at?: string
/** @example "8f9c71b8-b84e-4417-8408-a62274f65a08" */
subscription_id: string
/** @example "free" */
- subscription_name: string
+ subscription_name: EntitiesSubscriptionName
/** @example "2022-06-05T14:26:02.302718+03:00" */
- subscription_renews_at: string
+ subscription_renews_at?: string
/** @example "on_trial" */
- subscription_status: string
+ subscription_status?: string
/** @example "Europe/Helsinki" */
timezone: string
/** @example "2022-06-05T14:26:10.303278+03:00" */
@@ -243,11 +307,11 @@ export interface EntitiesUser {
export interface EntitiesWebhook {
/** @example "2022-06-05T14:26:02.302718+03:00" */
created_at: string
- /** @example ["[message.phone.received]"] */
+ /** @example ["message.phone.received"] */
events: string[]
/** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
id: string
- /** @example ["[+18005550199","+18005550100]"] */
+ /** @example ["+18005550199","+18005550100"] */
phone_numbers: string[]
/** @example "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */
signing_key: string
@@ -276,14 +340,34 @@ export interface RequestsHeartbeatStore {
phone_numbers: string[]
}
+export interface RequestsMessageAttachment {
+ /**
+ * Content is the base64-encoded attachment data
+ * @example "base64data..."
+ */
+ content: string
+ /**
+ * ContentType is the MIME type of the attachment
+ * @example "image/jpeg"
+ */
+ content_type: string
+ /**
+ * Name is the original filename of the attachment
+ * @example "photo.jpg"
+ */
+ name: string
+}
+
export interface RequestsMessageBulkSend {
+ /** Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS */
+ attachments?: string[]
/** @example "This is a sample text message" */
content: string
/**
* Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
* @example false
*/
- encrypted: boolean
+ encrypted?: boolean
/** @example "+18005550199" */
from: string
/**
@@ -325,6 +409,8 @@ export interface RequestsMessageEvent {
}
export interface RequestsMessageReceive {
+ /** Attachments is the list of MMS attachments received with the message */
+ attachments?: RequestsMessageAttachment[]
/** @example "This is a sample text message received on a phone" */
content: string
/**
@@ -338,7 +424,7 @@ export interface RequestsMessageReceive {
* SIM card that received the message
* @example "SIM1"
*/
- sim: string
+ sim: EntitiesSIM
/**
* Timestamp is the time when the event was emitted, Please send the timestamp in UTC with as much precision as possible
* @example "2022-06-05T14:26:09.527976+03:00"
@@ -349,13 +435,18 @@ export interface RequestsMessageReceive {
}
export interface RequestsMessageSend {
+ /**
+ * Attachments are optional. When you provide a list of attachments, the message will be sent out as an MMS
+ * @example ["https://example.com/image.jpg","https://example.com/video.mp4"]
+ */
+ attachments?: string[]
/** @example "This is a sample text message" */
content: string
/**
- * Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
+ * Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app
* @example false
*/
- encrypted: boolean
+ encrypted?: boolean
/** @example "+18005550199" */
from: string
/**
@@ -364,14 +455,26 @@ export interface RequestsMessageSend {
*/
request_id?: string
/**
- * SendAt is an optional parameter used to schedule a message to be sent at a later time
- * @example "2022-06-05T14:26:09.527976+03:00"
+ * SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.
+ * @example "2025-12-19T16:39:57-08:00"
*/
send_at?: string
/** @example "+18005550100" */
to: string
}
+export interface RequestsMessageSendScheduleStore {
+ name: string
+ timezone: string
+ windows: RequestsMessageSendScheduleWindow[]
+}
+
+export interface RequestsMessageSendScheduleWindow {
+ day_of_week: number
+ end_minute: number
+ start_minute: number
+}
+
export interface RequestsMessageThreadUpdate {
/** @example true */
is_archived: boolean
@@ -407,6 +510,8 @@ export interface RequestsPhoneUpsert {
* @example 12345
*/
message_expiration_seconds: number
+ /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
+ message_send_schedule_id?: string
/** @example 1 */
messages_per_minute: number
/** @example "e.g. This phone cannot receive calls. Please send an SMS instead." */
@@ -431,6 +536,23 @@ export interface RequestsUserNotificationUpdate {
webhook_enabled: boolean
}
+export interface RequestsUserPaymentInvoice {
+ /** @example "221B Baker Street, London" */
+ address: string
+ /** @example "Los Angeles" */
+ city: string
+ /** @example "US" */
+ country: string
+ /** @example "Acme Corp" */
+ name: string
+ /** @example "Thank you for your business!" */
+ notes: string
+ /** @example "CA" */
+ state: string
+ /** @example "9800" */
+ zip_code: string
+}
+
export interface RequestsUserUpdate {
/** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */
active_phone_id: string
@@ -465,7 +587,7 @@ export interface ResponsesBadRequest {
export interface ResponsesBillingUsageResponse {
data: EntitiesBillingUsage
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -473,7 +595,15 @@ export interface ResponsesBillingUsageResponse {
export interface ResponsesBillingUsagesResponse {
data: EntitiesBillingUsage[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
+ message: string
+ /** @example "success" */
+ status: string
+}
+
+export interface ResponsesBulkMessagesResponse {
+ data: EntitiesBulkMessage[]
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -481,7 +611,7 @@ export interface ResponsesBillingUsagesResponse {
export interface ResponsesDiscordResponse {
data: EntitiesDiscord
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -489,7 +619,7 @@ export interface ResponsesDiscordResponse {
export interface ResponsesDiscordsResponse {
data: EntitiesDiscord[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -497,7 +627,7 @@ export interface ResponsesDiscordsResponse {
export interface ResponsesHeartbeatResponse {
data: EntitiesHeartbeat
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -505,7 +635,7 @@ export interface ResponsesHeartbeatResponse {
export interface ResponsesHeartbeatsResponse {
data: EntitiesHeartbeat[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -520,7 +650,23 @@ export interface ResponsesInternalServerError {
export interface ResponsesMessageResponse {
data: EntitiesMessage
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
+ message: string
+ /** @example "success" */
+ status: string
+}
+
+export interface ResponsesMessageSendScheduleResponse {
+ data: EntitiesMessageSendSchedule
+ /** @example "Request handled successfully" */
+ message: string
+ /** @example "success" */
+ status: string
+}
+
+export interface ResponsesMessageSendSchedulesResponse {
+ data: EntitiesMessageSendSchedule[]
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -528,7 +674,7 @@ export interface ResponsesMessageResponse {
export interface ResponsesMessageThreadsResponse {
data: EntitiesMessageThread[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -536,7 +682,7 @@ export interface ResponsesMessageThreadsResponse {
export interface ResponsesMessagesResponse {
data: EntitiesMessage[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -564,9 +710,16 @@ export interface ResponsesOkString {
status: string
}
+export interface ResponsesPaymentRequired {
+ /** @example "You have reached the maximum number of allowed resources. Please upgrade your plan." */
+ message: string
+ /** @example "error" */
+ status: string
+}
+
export interface ResponsesPhoneAPIKeyResponse {
data: EntitiesPhoneAPIKey
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -574,7 +727,7 @@ export interface ResponsesPhoneAPIKeyResponse {
export interface ResponsesPhoneAPIKeysResponse {
data: EntitiesPhoneAPIKey[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -582,7 +735,7 @@ export interface ResponsesPhoneAPIKeysResponse {
export interface ResponsesPhoneResponse {
data: EntitiesPhone
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -590,7 +743,7 @@ export interface ResponsesPhoneResponse {
export interface ResponsesPhonesResponse {
data: EntitiesPhone[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -607,7 +760,7 @@ export interface ResponsesUnauthorized {
export interface ResponsesUnprocessableEntity {
data: Record
- /** @example "validation errors while sending message" */
+ /** @example "validation errors while handling request" */
message: string
/** @example "error" */
status: string
@@ -615,7 +768,47 @@ export interface ResponsesUnprocessableEntity {
export interface ResponsesUserResponse {
data: EntitiesUser
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
+ message: string
+ /** @example "success" */
+ status: string
+}
+
+export interface ResponsesUserSubscriptionPaymentsResponse {
+ data: {
+ attributes: {
+ billing_reason: string
+ card_brand: string
+ card_last_four: string
+ created_at: string
+ currency: string
+ currency_rate: string
+ discount_total: number
+ discount_total_formatted: string
+ discount_total_usd: number
+ refunded: boolean
+ refunded_amount: number
+ refunded_amount_formatted: string
+ refunded_amount_usd: number
+ refunded_at: any
+ status: string
+ status_formatted: string
+ subtotal: number
+ subtotal_formatted: string
+ subtotal_usd: number
+ tax: number
+ tax_formatted: string
+ tax_inclusive: boolean
+ tax_usd: number
+ total: number
+ total_formatted: string
+ total_usd: number
+ updated_at: string
+ }
+ id: string
+ type: string
+ }[]
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -623,7 +816,7 @@ export interface ResponsesUserResponse {
export interface ResponsesWebhookResponse {
data: EntitiesWebhook
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
@@ -631,7 +824,7 @@ export interface ResponsesWebhookResponse {
export interface ResponsesWebhooksResponse {
data: EntitiesWebhook[]
- /** @example "item created successfully" */
+ /** @example "Request handled successfully" */
message: string
/** @example "success" */
status: string
diff --git a/web/models/message.ts b/web/models/message.ts
index 35306648..b17b53ec 100644
--- a/web/models/message.ts
+++ b/web/models/message.ts
@@ -1,6 +1,7 @@
export interface Message {
contact: string
content: string
+ attachments: Array | null
created_at: string
failure_reason: string
id: string
diff --git a/web/package.json b/web/package.json
index a9290488..b7b85dfa 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,23 +26,23 @@
"@nuxtjs/dotenv": "^1.4.2",
"@nuxtjs/firebase": "^8.2.2",
"@nuxtjs/sitemap": "^2.4.0",
- "chart.js": "^4.5.0",
+ "chart.js": "^4.5.1",
"chartjs-adapter-moment": "^1.0.1",
- "core-js": "^3.45.1",
+ "core-js": "^3.49.0",
"date-fns": "^2.30.0",
- "dotenv": "^17.2.1",
+ "dotenv": "^17.2.3",
"firebase": "^10.14.1",
"firebaseui": "^6.1.0",
- "jest-environment-jsdom": "^30.2.0",
- "libphonenumber-js": "^1.12.9",
+ "jest-environment-jsdom": "^30.3.0",
+ "libphonenumber-js": "^1.12.36",
"moment": "^2.30.1",
"nuxt": "^2.18.1",
"nuxt-highlightjs": "^1.0.3",
"pusher-js": "^8.4.0",
"qrcode": "^1.5.0",
- "ufo": "^1.6.1",
+ "ufo": "^1.6.4",
"vue": "^2.7.16",
- "vue-chartjs": "^5.3.2",
+ "vue-chartjs": "^5.3.3",
"vue-class-component": "^7.2.6",
"vue-glow": "^1.4.2",
"vue-property-decorator": "^9.1.2",
@@ -51,21 +51,21 @@
"vue-template-compiler": "^2.7.16",
"vuetify": "^2.7.2",
"vuex": "^3.6.2",
- "webpack": "^5.102.0"
+ "webpack": "^5.104.1"
},
"devDependencies": {
- "@babel/eslint-parser": "^7.27.5",
- "@commitlint/cli": "^20.1.0",
- "@commitlint/config-conventional": "^19.8.0",
+ "@babel/eslint-parser": "^7.28.6",
+ "@commitlint/cli": "^20.4.0",
+ "@commitlint/config-conventional": "^20.5.3",
"@nuxt/types": "^2.18.1",
"@nuxt/typescript-build": "^3.0.2",
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@nuxtjs/eslint-module": "^4.1.0",
"@nuxtjs/stylelint-module": "^5.2.0",
"@nuxtjs/vuetify": "^1.12.3",
- "@types/qrcode": "^1.5.5",
+ "@types/qrcode": "^1.5.6",
"@vue/test-utils": "^1.3.6",
- "axios": "^0.30.2",
+ "axios": "^0.32.0",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^30.2.0",
"eslint": "^8.57.1",
@@ -76,13 +76,13 @@
"jest": "^30.2.0",
"lint-staged": "^16.1.4",
"node-fetch-native": "^1.6.7",
- "postcss-html": "^1.7.0",
- "prettier": "3.6.2",
+ "postcss-html": "^1.8.1",
+ "prettier": "3.8.1",
"stylelint": "^15.11.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^34.0.0",
- "ts-jest": "^29.4.4",
+ "ts-jest": "^29.4.6",
"vue-client-only": "^2.1.0",
"vue-jest": "^3.0.7",
"vue-meta": "^2.4.0",
diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue
index d17b7c9a..ee98235c 100644
--- a/web/pages/billing/index.vue
+++ b/web/pages/billing/index.vue
@@ -226,16 +226,28 @@
- Overview
+ Overview
This is the summary of the sent messages and received messages in
{{
- $store.getters.getBillingUsage.start_timestamp | billingPeriod
- }}.
+ v-html="
+ $options.filters.billingPeriodDateOrdinal(
+ $store.getters.getBillingUsage.start_timestamp,
+ )
+ "
+ />
+ to
+ .
@@ -288,16 +300,97 @@
- Usage History
+
+ Subscription Payments
+
+ This is a list of your last 10 subscription payments made using
+ our payment provider
+ Lemon Squeezy.
+
+
+
+
+
+
+ |
+ ID
+ |
+ Timestamp |
+ Status |
+
+ Tax
+ |
+ Total |
+ |
+
+
+
+
+ |
+ {{ payment.id }}
+ |
+
+ {{ payment.attributes.created_at | timestamp }}
+ |
+
+
+
+ {{ mdiCheck }}
+
+ {{ payment.attributes.status_formatted }}
+
+
+
+ {{ mdiAlert }}
+
+ {{ payment.attributes.status_formatted }}
+
+ |
+
+ {{ payment.attributes.tax_formatted }}
+ |
+
+ {{ payment.attributes.total_formatted }}
+ |
+
+
+ {{ mdiInvoice }}
+ Invoice
+
+ |
+
+
+
+
+
+ Usage History
Summary of all the sent and received messages in the past 12
- months
+ billing periods
- | Period |
+ Start Date |
+ End Date |
Sent
Messages
@@ -306,9 +399,6 @@
Received
Messages
|
-
- Total Cost
- |
@@ -317,18 +407,26 @@
.getBillingUsageHistory"
:key="billingUsage.id"
>
-
- {{ billingUsage.start_timestamp | billingPeriod }}
- |
+ |
+ |
{{ billingUsage.sent_messages | decimal }}
|
{{ billingUsage.received_messages }}
|
-
- {{ billingUsage.total_cost | money }}
- |
@@ -337,6 +435,150 @@
+
+
+ Generate Invoice
+
+ Create an invoice for your
+ {{ selectedPayment?.attributes.total_formatted }} payment on
+ {{ selectedPayment?.attributes.created_at | timestamp }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ mdiDownloadOutline }}
+ Download Invoice
+
+
+
+ Close
+
+
+
+
@@ -347,8 +589,12 @@ import {
mdiAccountCircle,
mdiShieldCheck,
mdiDelete,
+ mdiDownloadOutline,
mdiCog,
mdiContentSave,
+ mdiCheck,
+ mdiAlert,
+ mdiInvoice,
mdiEye,
mdiEyeOff,
mdiCallReceived,
@@ -356,6 +602,11 @@ import {
mdiCreditCard,
mdiSquareEditOutline,
} from '@mdi/js'
+import {
+ RequestsUserPaymentInvoice,
+ ResponsesUserSubscriptionPaymentsResponse,
+} from '~/models/api'
+import { ErrorMessages } from '~/plugins/errors'
type PaymentPlan = {
name: string
@@ -364,6 +615,14 @@ type PaymentPlan = {
messagesPerMonth: number
}
+type subscriptionPayment = {
+ attributes: {
+ created_at: string
+ total_formatted: string
+ }
+ id: string
+}
+
export default Vue.extend({
name: 'BillingIndex',
middleware: ['auth'],
@@ -372,7 +631,11 @@ export default Vue.extend({
mdiEye,
mdiEyeOff,
mdiArrowLeft,
+ mdiDownloadOutline,
mdiAccountCircle,
+ mdiCheck,
+ mdiAlert,
+ mdiInvoice,
mdiShieldCheck,
mdiDelete,
mdiCog,
@@ -382,7 +645,267 @@ export default Vue.extend({
mdiCreditCard,
mdiSquareEditOutline,
loading: true,
+ loadingSubscriptionPayments: false,
dialog: false,
+ payments: null as ResponsesUserSubscriptionPaymentsResponse | null,
+ selectedPayment: null as subscriptionPayment | null,
+ errorMessages: new ErrorMessages(),
+ invoiceFormName: '',
+ invoiceFormAddress: '',
+ invoiceFormCity: '',
+ invoiceFormState: '',
+ invoiceFormZipCode: '',
+ invoiceFormCountry: '',
+ invoiceFormNotes: '',
+ subscriptionInvoiceDialog: false,
+ countries: [
+ { text: 'Afghanistan', value: 'AF' },
+ { text: 'Åland Islands', value: 'AX' },
+ { text: 'Albania', value: 'AL' },
+ { text: 'Algeria', value: 'DZ' },
+ { text: 'American Samoa', value: 'AS' },
+ { text: 'Andorra', value: 'AD' },
+ { text: 'Angola', value: 'AO' },
+ { text: 'Anguilla', value: 'AI' },
+ { text: 'Antarctica', value: 'AQ' },
+ { text: 'Antigua and Barbuda', value: 'AG' },
+ { text: 'Argentina', value: 'AR' },
+ { text: 'Armenia', value: 'AM' },
+ { text: 'Aruba', value: 'AW' },
+ { text: 'Australia', value: 'AU' },
+ { text: 'Austria', value: 'AT' },
+ { text: 'Azerbaijan', value: 'AZ' },
+ { text: 'Bahamas', value: 'BS' },
+ { text: 'Bahrain', value: 'BH' },
+ { text: 'Bangladesh', value: 'BD' },
+ { text: 'Barbados', value: 'BB' },
+ { text: 'Belarus', value: 'BY' },
+ { text: 'Belgium', value: 'BE' },
+ { text: 'Belize', value: 'BZ' },
+ { text: 'Benin', value: 'BJ' },
+ { text: 'Bermuda', value: 'BM' },
+ { text: 'Bhutan', value: 'BT' },
+ { text: 'Bolivia', value: 'BO' },
+ { text: 'Bonaire', value: 'BQ' },
+ { text: 'Bosnia and Herzegovina', value: 'BA' },
+ { text: 'Botswana', value: 'BW' },
+ { text: 'Bouvet Island', value: 'BV' },
+ { text: 'Brazil', value: 'BR' },
+ { text: 'British Indian Ocean', value: 'IO' },
+ { text: 'Brunei Darussalam', value: 'BN' },
+ { text: 'Bulgaria', value: 'BG' },
+ { text: 'Burkina Faso', value: 'BF' },
+ { text: 'Burundi', value: 'BI' },
+ { text: 'Cabo Verde', value: 'CV' },
+ { text: 'Cambodia', value: 'KH' },
+ { text: 'Cameroon', value: 'CM' },
+ { text: 'Canada', value: 'CA' },
+ { text: 'Cayman Islands', value: 'KY' },
+ { text: 'Central African Republic', value: 'CF' },
+ { text: 'Chad', value: 'TD' },
+ { text: 'Chile', value: 'CL' },
+ { text: 'China', value: 'CN' },
+ { text: 'Christmas Island', value: 'CX' },
+ { text: 'Cocos (Keeling) Islands', value: 'CC' },
+ { text: 'Colombia', value: 'CO' },
+ { text: 'Comoros', value: 'KM' },
+ { text: 'Congo', value: 'CG' },
+ { text: 'Congo', value: 'CD' },
+ { text: 'Cook Islands', value: 'CK' },
+ { text: 'Costa Rica', value: 'CR' },
+ { text: "Côte d'Ivoire", value: 'CI' },
+ { text: 'Cuba', value: 'CU' },
+ { text: 'Curaçao', value: 'CW' },
+ { text: 'Cyprus', value: 'CY' },
+ { text: 'Czechia', value: 'CZ' },
+ { text: 'Denmark', value: 'DK' },
+ { text: 'Djibouti', value: 'DJ' },
+ { text: 'Dominica', value: 'DM' },
+ { text: 'Dominican Republic', value: 'DO' },
+ { text: 'Ecuador', value: 'EC' },
+ { text: 'Egypt', value: 'EG' },
+ { text: 'El Salvador', value: 'SV' },
+ { text: 'Equatorial Guinea', value: 'GQ' },
+ { text: 'Eritrea', value: 'ER' },
+ { text: 'Estonia', value: 'EE' },
+ { text: 'Eswatini', value: 'SZ' },
+ { text: 'Ethiopia', value: 'ET' },
+ { text: 'Falkland Islands', value: 'FK' },
+ { text: 'Faroe Islands', value: 'FO' },
+ { text: 'Fiji', value: 'FJ' },
+ { text: 'Finland', value: 'FI' },
+ { text: 'France', value: 'FR' },
+ { text: 'French Guiana', value: 'GF' },
+ { text: 'French Polynesia', value: 'PF' },
+ { text: 'French Southern Territories', value: 'TF' },
+ { text: 'Gabon', value: 'GA' },
+ { text: 'Gambia', value: 'GM' },
+ { text: 'Georgia', value: 'GE' },
+ { text: 'Germany', value: 'DE' },
+ { text: 'Ghana', value: 'GH' },
+ { text: 'Gibraltar', value: 'GI' },
+ { text: 'Greece', value: 'GR' },
+ { text: 'Greenland', value: 'GL' },
+ { text: 'Grenada', value: 'GD' },
+ { text: 'Guadeloupe', value: 'GP' },
+ { text: 'Guam', value: 'GU' },
+ { text: 'Guatemala', value: 'GT' },
+ { text: 'Guernsey', value: 'GG' },
+ { text: 'Guinea', value: 'GN' },
+ { text: 'Guinea-Bissau', value: 'GW' },
+ { text: 'Guyana', value: 'GY' },
+ { text: 'Haiti', value: 'HT' },
+ { text: 'Heard Island and McDonald Islands', value: 'HM' },
+ { text: 'Holy See', value: 'VA' },
+ { text: 'Honduras', value: 'HN' },
+ { text: 'Hong Kong', value: 'HK' },
+ { text: 'Hungary', value: 'HU' },
+ { text: 'Iceland', value: 'IS' },
+ { text: 'India', value: 'IN' },
+ { text: 'Indonesia', value: 'ID' },
+ { text: 'Iran', value: 'IR' },
+ { text: 'Iraq', value: 'IQ' },
+ { text: 'Ireland', value: 'IE' },
+ { text: 'Isle of Man', value: 'IM' },
+ { text: 'Israel', value: 'IL' },
+ { text: 'Italy', value: 'IT' },
+ { text: 'Jamaica', value: 'JM' },
+ { text: 'Japan', value: 'JP' },
+ { text: 'Jersey', value: 'JE' },
+ { text: 'Jordan', value: 'JO' },
+ { text: 'Kazakhstan', value: 'KZ' },
+ { text: 'Kenya', value: 'KE' },
+ { text: 'Kiribati', value: 'KI' },
+ { text: 'North Korea', value: 'KP' },
+ { text: 'South Korea', value: 'KR' },
+ { text: 'Kuwait', value: 'KW' },
+ { text: 'Kyrgyzstan', value: 'KG' },
+ { text: 'Lao People’s Democratic Republic', value: 'LA' },
+ { text: 'Latvia', value: 'LV' },
+ { text: 'Lebanon', value: 'LB' },
+ { text: 'Lesotho', value: 'LS' },
+ { text: 'Liberia', value: 'LR' },
+ { text: 'Libya', value: 'LY' },
+ { text: 'Liechtenstein', value: 'LI' },
+ { text: 'Lithuania', value: 'LT' },
+ { text: 'Luxembourg', value: 'LU' },
+ { text: 'Macao', value: 'MO' },
+ { text: 'Madagascar', value: 'MG' },
+ { text: 'Malawi', value: 'MW' },
+ { text: 'Malaysia', value: 'MY' },
+ { text: 'Maldives', value: 'MV' },
+ { text: 'Mali', value: 'ML' },
+ { text: 'Malta', value: 'MT' },
+ { text: 'Marshall Islands', value: 'MH' },
+ { text: 'Martinique', value: 'MQ' },
+ { text: 'Mauritania', value: 'MR' },
+ { text: 'Mauritius', value: 'MU' },
+ { text: 'Mayotte', value: 'YT' },
+ { text: 'Mexico', value: 'MX' },
+ { text: 'Micronesia', value: 'FM' },
+ { text: 'Moldova', value: 'MD' },
+ { text: 'Monaco', value: 'MC' },
+ { text: 'Mongolia', value: 'MN' },
+ { text: 'Montenegro', value: 'ME' },
+ { text: 'Montserrat', value: 'MS' },
+ { text: 'Morocco', value: 'MA' },
+ { text: 'Mozambique', value: 'MZ' },
+ { text: 'Myanmar', value: 'MM' },
+ { text: 'Namibia', value: 'NA' },
+ { text: 'Nauru', value: 'NR' },
+ { text: 'Nepal', value: 'NP' },
+ { text: 'Netherlands', value: 'NL' },
+ { text: 'New Caledonia', value: 'NC' },
+ { text: 'New Zealand', value: 'NZ' },
+ { text: 'Nicaragua', value: 'NI' },
+ { text: 'Niger', value: 'NE' },
+ { text: 'Nigeria', value: 'NG' },
+ { text: 'Niue', value: 'NU' },
+ { text: 'Norfolk Island', value: 'NF' },
+ { text: 'North Macedonia', value: 'MK' },
+ { text: 'Northern Mariana Islands', value: 'MP' },
+ { text: 'Norway', value: 'NO' },
+ { text: 'Oman', value: 'OM' },
+ { text: 'Pakistan', value: 'PK' },
+ { text: 'Palau', value: 'PW' },
+ { text: 'Panama', value: 'PA' },
+ { text: 'Papua New Guinea', value: 'PG' },
+ { text: 'Paraguay', value: 'PY' },
+ { text: 'Peru', value: 'PE' },
+ { text: 'Philippines', value: 'PH' },
+ { text: 'Pitcairn', value: 'PN' },
+ { text: 'Poland', value: 'PL' },
+ { text: 'Portugal', value: 'PT' },
+ { text: 'Puerto Rico', value: 'PR' },
+ { text: 'Qatar', value: 'QA' },
+ { text: 'Réunion', value: 'RE' },
+ { text: 'Romania', value: 'RO' },
+ { text: 'Russian Federation', value: 'RU' },
+ { text: 'Rwanda', value: 'RW' },
+ { text: 'Saint Barthélemy', value: 'BL' },
+ { text: 'Saint Helena, Ascension and Tristan da Cunha', value: 'SH' },
+ { text: 'Saint Kitts and Nevis', value: 'KN' },
+ { text: 'Saint Lucia', value: 'LC' },
+ { text: 'Saint Martin (French part)', value: 'MF' },
+ { text: 'Saint Pierre and Miquelon', value: 'PM' },
+ { text: 'Saint Vincent and the Grenadines', value: 'VC' },
+ { text: 'Samoa', value: 'WS' },
+ { text: 'San Marino', value: 'SM' },
+ { text: 'Sao Tome and Principe', value: 'ST' },
+ { text: 'Saudi Arabia', value: 'SA' },
+ { text: 'Senegal', value: 'SN' },
+ { text: 'Serbia', value: 'RS' },
+ { text: 'Seychelles', value: 'SC' },
+ { text: 'Sierra Leone', value: 'SL' },
+ { text: 'Singapore', value: 'SG' },
+ { text: 'Slovakia', value: 'SK' },
+ { text: 'Slovenia', value: 'SI' },
+ { text: 'Solomon Islands', value: 'SB' },
+ { text: 'Somalia', value: 'SO' },
+ { text: 'South Africa', value: 'ZA' },
+ { text: 'South Georgia and the South Sandwich Islands', value: 'GS' },
+ { text: 'South Sudan', value: 'SS' },
+ { text: 'Spain', value: 'ES' },
+ { text: 'Sri Lanka', value: 'LK' },
+ { text: 'Sudan', value: 'SD' },
+ { text: 'Suriname', value: 'SR' },
+ { text: 'Svalbard and Jan Mayen', value: 'SJ' },
+ { text: 'Sweden', value: 'SE' },
+ { text: 'Switzerland', value: 'CH' },
+ { text: 'Syrian Arab Republic', value: 'SY' },
+ { text: 'Taiwan, Province of China', value: 'TW' },
+ { text: 'Tajikistan', value: 'TJ' },
+ { text: 'Tanzania, United Republic of', value: 'TZ' },
+ { text: 'Thailand', value: 'TH' },
+ { text: 'Timor-Leste', value: 'TL' },
+ { text: 'Togo', value: 'TG' },
+ { text: 'Tokelau', value: 'TK' },
+ { text: 'Tonga', value: 'TO' },
+ { text: 'Trinidad and Tobago', value: 'TT' },
+ { text: 'Tunisia', value: 'TN' },
+ { text: 'Turkey', value: 'TR' },
+ { text: 'Turkmenistan', value: 'TM' },
+ { text: 'Turks and Caicos Islands', value: 'TC' },
+ { text: 'Tuvalu', value: 'TV' },
+ { text: 'Uganda', value: 'UG' },
+ { text: 'Ukraine', value: 'UA' },
+ { text: 'United Arab Emirates', value: 'AE' },
+ { text: 'United Kingdom', value: 'GB' },
+ { text: 'United States', value: 'US' },
+ { text: 'United States Minor Outlying Islands', value: 'UM' },
+ { text: 'Uruguay', value: 'UY' },
+ { text: 'Uzbekistan', value: 'UZ' },
+ { text: 'Vanuatu', value: 'VU' },
+ { text: 'Venezuela', value: 'VE' },
+ { text: 'Viet Nam', value: 'VN' },
+ { text: 'Virgin Islands (British)', value: 'VG' },
+ { text: 'Virgin Islands (U.S.)', value: 'VI' },
+ { text: 'Wallis and Futuna', value: 'WF' },
+ { text: 'Western Sahara', value: 'EH' },
+ { text: 'Yemen', value: 'YE' },
+ { text: 'Zambia', value: 'ZM' },
+ { text: 'Zimbabwe', value: 'ZW' },
+ ],
plans: [
{
name: 'Free',
@@ -459,6 +982,81 @@ export default Vue.extend({
}
},
computed: {
+ invoiceStateOptions() {
+ if (this.invoiceFormCountry === 'US') {
+ return [
+ { text: 'Alabama', value: 'AL' },
+ { text: 'Alaska', value: 'AK' },
+ { text: 'Arizona', value: 'AZ' },
+ { text: 'Arkansas', value: 'AR' },
+ { text: 'California', value: 'CA' },
+ { text: 'Colorado', value: 'CO' },
+ { text: 'Connecticut', value: 'CT' },
+ { text: 'Delaware', value: 'DE' },
+ { text: 'Florida', value: 'FL' },
+ { text: 'Georgia', value: 'GA' },
+ { text: 'Hawaii', value: 'HI' },
+ { text: 'Idaho', value: 'ID' },
+ { text: 'Illinois', value: 'IL' },
+ { text: 'Indiana', value: 'IN' },
+ { text: 'Iowa', value: 'IA' },
+ { text: 'Kansas', value: 'KS' },
+ { text: 'Kentucky', value: 'KY' },
+ { text: 'Louisiana', value: 'LA' },
+ { text: 'Maine', value: 'ME' },
+ { text: 'Maryland', value: 'MD' },
+ { text: 'Massachusetts', value: 'MA' },
+ { text: 'Michigan', value: 'MI' },
+ { text: 'Minnesota', value: 'MN' },
+ { text: 'Mississippi', value: 'MS' },
+ { text: 'Missouri', value: 'MO' },
+ { text: 'Montana', value: 'MT' },
+ { text: 'Nebraska', value: 'NE' },
+ { text: 'Nevada', value: 'NV' },
+ { text: 'New Hampshire', value: 'NH' },
+ { text: 'New Jersey', value: 'NJ' },
+ { text: 'New Mexico', value: 'NM' },
+ { text: 'New York', value: 'NY' },
+ { text: 'North Carolina', value: 'NC' },
+ { text: 'North Dakota', value: 'ND' },
+ { text: 'Ohio', value: 'OH' },
+ { text: 'Oklahoma', value: 'OK' },
+ { text: 'Oregon', value: 'OR' },
+ { text: 'Pennsylvania', value: 'PA' },
+ { text: 'Rhode Island', value: 'RI' },
+ { text: 'South Carolina', value: 'SC' },
+ { text: 'South Dakota', value: 'SD' },
+ { text: 'Tennessee', value: 'TN' },
+ { text: 'Texas', value: 'TX' },
+ { text: 'Utah', value: 'UT' },
+ { text: 'Vermont', value: 'VT' },
+ { text: 'Virginia', value: 'VA' },
+ { text: 'Washington', value: 'WA' },
+ { text: 'West Virginia', value: 'WV' },
+ { text: 'Wisconsin', value: 'WI' },
+ { text: 'Wyoming', value: 'WY' },
+ { text: 'District of Columbia', value: 'DC' },
+ ]
+ }
+ if (this.invoiceFormCountry === 'CA') {
+ return [
+ { text: 'Alberta', value: 'AB' },
+ { text: 'British Columbia', value: 'BC' },
+ { text: 'Manitoba', value: 'MB' },
+ { text: 'New Brunswick', value: 'NB' },
+ { text: 'Newfoundland and Labrador', value: 'NL' },
+ { text: 'Nova Scotia', value: 'NS' },
+ { text: 'Ontario', value: 'ON' },
+ { text: 'Prince Edward Island', value: 'PE' },
+ { text: 'Quebec', value: 'QC' },
+ { text: 'Saskatchewan', value: 'SK' },
+ { text: 'Northwest Territories', value: 'NT' },
+ { text: 'Nunavut', value: 'NU' },
+ { text: 'Yukon', value: 'YT' },
+ ]
+ }
+ return []
+ },
checkoutURL() {
const url = new URL(this.$config.checkoutURL)
const user = this.$store.getters.getAuthUser
@@ -513,7 +1111,51 @@ export default Vue.extend({
this.$store.dispatch('loadBillingUsageHistory'),
])
this.loading = false
+ this.loadSubscriptionInvoices()
+ },
+
+ loadSubscriptionInvoices() {
+ this.loadingSubscriptionPayments = true
+ this.$store
+ .dispatch('indexSubscriptionPayments')
+ .then((response: ResponsesUserSubscriptionPaymentsResponse) => {
+ this.payments = response
+ })
+ .finally(() => {
+ this.loadingSubscriptionPayments = false
+ })
},
+
+ generateInvoice() {
+ this.errorMessages = new ErrorMessages()
+ this.loading = true
+ this.$store
+ .dispatch('generateSubscriptionPaymentInvoice', {
+ subscriptionInvoiceId: this.selectedPayment?.id || '',
+ request: {
+ name: this.invoiceFormName,
+ address: this.invoiceFormAddress,
+ city: this.invoiceFormCity,
+ state: this.invoiceFormState,
+ zip_code: this.invoiceFormZipCode,
+ country: this.invoiceFormCountry,
+ notes: this.invoiceFormNotes,
+ },
+ } as {
+ subscriptionInvoiceId: string
+ request: RequestsUserPaymentInvoice
+ })
+ .then(() => {
+ this.subscriptionInvoiceDialog = false
+ })
+ .catch((error: ErrorMessages) => {
+ this.errorMessages = error
+ })
+ .finally(() => {
+ this.loading = false
+ })
+ },
+
updateDetails() {
this.loading = true
this.$store
@@ -540,6 +1182,11 @@ export default Vue.extend({
this.loading = false
})
},
+
+ showInvoiceDialog(payment: subscriptionPayment) {
+ this.selectedPayment = payment
+ this.subscriptionInvoiceDialog = true
+ },
},
})
diff --git a/web/pages/bulk-messages/index.vue b/web/pages/bulk-messages/index.vue
index 5b8f2011..3e3b5700 100644
--- a/web/pages/bulk-messages/index.vue
+++ b/web/pages/bulk-messages/index.vue
@@ -39,7 +39,15 @@
>Excel template
and upload it here to send your SMS messages to multiple
- recipients at once.
+ recipients at once. You can also configure
+ send schedules
+ on your phone to make sure messages are sent out at specific times
+ of the day e.g
+ Mon - Fri 9am - 5pm.
{{ errorTitle }}
@@ -88,6 +96,62 @@
+
+
+ Bulk Message History
+
+ Your 10 most recent bulk SMS uploads are shown below, including a
+ delivery status breakdown for each batch. Click on a row to see
+ individual messages on the search page.
+
+
+
+
+
+
+ | Name |
+ Created At |
+ Total |
+ Pending |
+ Scheduled |
+ Sent |
+ Delivered |
+ Failed |
+ Expired |
+
+
+
+
+ |
+ {{ cleanName(order.request_id) }}
+ |
+
+ {{ order.created_at | timestamp }}
+ |
+ {{ order.total }} |
+ {{ order.pending_count }} |
+ {{ order.scheduled_count }} |
+ {{ order.sent_count }} |
+ {{ order.delivered_count }} |
+ {{ order.failed_count }} |
+ {{ order.expired_count }} |
+
+
+
+
+
+
@@ -137,9 +201,11 @@ export default Vue.extend({
mdiSquareEditOutline,
formFile: null,
loading: true,
+ loadingHistory: true,
errorTitle: '',
errorMessages: new ErrorMessages(),
dialog: false,
+ bulkOrders: [] as any[],
}
},
head() {
@@ -151,8 +217,37 @@ export default Vue.extend({
async mounted() {
await this.$store.dispatch('loadUser')
this.loading = false
+ this.fetchBulkOrders()
},
methods: {
+ cleanName(requestId: string): string {
+ if (requestId.startsWith('bulk-csv-')) {
+ return requestId.replace(/^bulk-csv-/, '') + '.csv'
+ }
+ if (requestId.startsWith('bulk-xls-')) {
+ return requestId.replace(/^bulk-xls-/, '') + '.xlsx'
+ }
+ // New format: bulk-{base62_timestamp}-{filename}
+ const newFormatMatch = requestId.match(/^bulk-[0-9A-Za-z]+-(.+)$/)
+ if (newFormatMatch) {
+ return newFormatMatch[1]
+ }
+ return requestId.replace(/^bulk-/, '')
+ },
+ fetchBulkOrders() {
+ this.loadingHistory = true
+ this.$store
+ .dispatch('fetchBulkMessageOrders')
+ .then((orders: any[]) => {
+ this.bulkOrders = orders
+ })
+ .catch(() => {
+ // silently fail - the table will show "no data"
+ })
+ .finally(() => {
+ this.loadingHistory = false
+ })
+ },
sendBulkMessages() {
this.loading = true
this.errorMessages = new ErrorMessages()
@@ -161,10 +256,9 @@ export default Vue.extend({
this.$store
.dispatch('sendBulkMessages', this.formFile)
.then(() => {
- setTimeout(() => {
- this.loading = false
- this.$router.push({ name: 'threads' })
- }, 2000)
+ this.loading = false
+ this.formFile = null
+ this.fetchBulkOrders()
})
.catch((error: AxiosError) => {
this.errorTitle = capitalize(
@@ -178,3 +272,13 @@ export default Vue.extend({
},
})
+
+
diff --git a/web/pages/index.vue b/web/pages/index.vue
index 3889e88d..731aaecb 100644
--- a/web/pages/index.vue
+++ b/web/pages/index.vue
@@ -51,8 +51,8 @@
- ⚡Trusted by 13,195+ happy users who have sent or received
- more than 5,263,593+ messages.
+ ⚡Trusted by 23,273+ users who send/receive more than
+ 500,000 messages per month.
-
+
{{ pricingLabels[pricing] }}
@@ -951,7 +951,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
Can I install the app on my Iphone?
-
+
{{ mdiMinus }}
{{ mdiPlus }}
@@ -967,7 +967,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
What's the minimum supported Android version?
-
+
{{ mdiMinus }}
{{ mdiPlus }}
@@ -983,7 +983,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
Can I send unlimited number of messages per month?
-
+
{{ mdiMinus }}
{{ mdiPlus }}
@@ -1001,7 +1001,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync());
Can I change the sender of the SMS message
-
+
{{ mdiMinus }}
{{ mdiPlus }}
diff --git a/web/pages/login.vue b/web/pages/login.vue
index 0012028f..c2346075 100644
--- a/web/pages/login.vue
+++ b/web/pages/login.vue
@@ -14,9 +14,9 @@
Welcome
- Join 13,195+ happy users who have sent or
+ Join 23,273+ users who send/receive more than
- received more than 5,263,593+ SMS messages
+ 500,000 messages per month
diff --git a/web/pages/messages/index.vue b/web/pages/messages/index.vue
index a1a2a154..3f4f4e99 100644
--- a/web/pages/messages/index.vue
+++ b/web/pages/messages/index.vue
@@ -17,6 +17,7 @@
+
x.trim() !== '')
+ .map((x) => x.trim()),
sim: this.simSelected.code,
})
.then(() => {
@@ -113,6 +136,9 @@ export default {
),
)
}
+ if (response.data.data.attachments) {
+ errors.set('attachments', response.data.data.attachments)
+ }
if (response.data.data.from) {
this.$store.dispatch('addNotification', {
message: response.data.data.from[0],
diff --git a/web/pages/phone-api-keys/index.vue b/web/pages/phone-api-keys/index.vue
index dc19e75c..e83a2d70 100644
--- a/web/pages/phone-api-keys/index.vue
+++ b/web/pages/phone-api-keys/index.vue
@@ -24,9 +24,9 @@
- {{ mdiMagnify }}
- Search Messages
+ {{
+ mdiMagnify
+ }}
+ SEARCH
+ Search Messages
@@ -120,8 +123,11 @@
v-bind="attrs"
v-on="on"
>
- {{ mdiDelete }}
- Delete messages
+ {{
+ mdiDelete
+ }}
+ DELETE
+ Delete messages
@@ -139,7 +145,6 @@
:loading="loading"
@click="deleteMessages"
>
- {{ mdiDelete }}
Yes Delete Messages
@@ -147,6 +152,46 @@
+
+
+
+ {{ mdiRefresh }}
+ Resend Messages
+
+
+
+
+ Are you sure you want to resend the
+ {{ selectedMessages.length }} selected messages?
+
+
+ The selected messages will be queued for sending again using
+ the original sender, recipient, and content.
+
+
+
+ Yes Resend Messages
+
+
+ Close
+
+
+
- {{ mdiExport }}
- Export to CSV
+ {{
+ mdiExport
+ }}
+ EXPORT
+ Export to CSV
@@ -280,11 +328,13 @@ import {
mdiCallReceived,
mdiCallMade,
mdiExport,
+ mdiRefresh,
mdiProgressCheck,
mdiAlert,
} from '@mdi/js'
import { AxiosError } from 'axios'
import { DataOptions } from 'vuetify'
+import axios from '~/plugins/axios'
import { ErrorMessages, getErrorMessages } from '~/plugins/errors'
import capitalize from '~/plugins/capitalize'
import {
@@ -317,6 +367,7 @@ export default Vue.extend({
mdiMagnify,
mdiArrowLeft,
mdiExport,
+ mdiRefresh,
mdiAlert,
mdiCheck,
mdiCheckAll,
@@ -325,8 +376,10 @@ export default Vue.extend({
mdiCallMade,
mdiProgressCheck,
loading: true,
+ initialLoadComplete: false,
errorTitle: '',
showDeleteDialog: false,
+ showResendDialog: false,
selectedMessages: [] as EntitiesMessage[],
errorMessages: new ErrorMessages(),
formOwners: [],
@@ -359,6 +412,16 @@ export default Vue.extend({
}
},
computed: {
+ canResendSelected(): boolean {
+ return (
+ this.selectedMessages.length > 0 &&
+ this.selectedMessages.every(
+ (message: EntitiesMessage) =>
+ message.type === 'mobile-terminated' &&
+ (message.status === 'expired' || message.status === 'failed'),
+ )
+ )
+ },
phoneNumberSelectItems() {
return this.$store.getters.getPhones.map((phone: EntitiesPhone) => {
return {
@@ -388,6 +451,9 @@ export default Vue.extend({
watch: {
options: {
handler() {
+ if (!this.initialLoadComplete) {
+ return
+ }
this.fetchMessages()
},
deep: true,
@@ -396,7 +462,20 @@ export default Vue.extend({
async mounted() {
await this.$store.dispatch('loadUser')
await this.$store.dispatch('loadPhones')
+
+ // Auto-fill search query from URL params
+ const queryParam = this.$route.query.query
+ if (queryParam && typeof queryParam === 'string') {
+ this.formQuery = queryParam
+ }
+
this.loading = false
+ this.initialLoadComplete = true
+
+ // Auto-search if query param was provided
+ if (this.formQuery) {
+ this.fetchMessages(true)
+ }
},
methods: {
@@ -478,6 +557,49 @@ export default Vue.extend({
})
},
+ resendMessages() {
+ this.loading = true
+ Promise.allSettled(
+ this.selectedMessages.map((message) =>
+ axios.post('/v1/messages/send', {
+ from: message.owner,
+ to: message.contact,
+ content: message.content,
+ sim: message.sim,
+ request_id: message.request_id,
+ }),
+ ),
+ )
+ .then((results) => {
+ const failed = results.filter((r) => r.status === 'rejected')
+ if (failed.length === 0) {
+ this.$store.dispatch('addNotification', {
+ message: 'The selected messages have been queued for resending',
+ type: 'success',
+ })
+ this.selectedMessages = []
+ } else if (failed.length === results.length) {
+ this.$store.dispatch('addNotification', {
+ message: 'Error while resending the selected messages',
+ type: 'error',
+ })
+ } else {
+ this.$store.dispatch('addNotification', {
+ message: `${results.length - failed.length} messages resent, ${
+ failed.length
+ } failed`,
+ type: 'warning',
+ })
+ this.selectedMessages = []
+ }
+ })
+ .finally(() => {
+ this.loading = false
+ this.showResendDialog = false
+ this.fetchMessages()
+ })
+ },
+
fetchMessages(reset = false) {
this.loading = true
this.errorMessages = new ErrorMessages()
diff --git a/web/pages/settings/index.vue b/web/pages/settings/index.vue
index 10cbf94f..503fd3a4 100644
--- a/web/pages/settings/index.vue
+++ b/web/pages/settings/index.vue
@@ -50,6 +50,7 @@
@change="updateTimezone"
>
+
API Key
Use your API Key in the x-api-key HTTP Header when
@@ -210,6 +211,7 @@
{{ event }}
@@ -244,6 +246,7 @@
>Documentation
+
Discord Integration
@@ -310,6 +313,7 @@
>
Add Discord
+
Phones
List of mobile phones which are registered for sending and
@@ -357,6 +361,7 @@
@@ -370,6 +375,79 @@
+
+
+ Send Schedules
+
+
+ Create availability schedules and attach them to each phone.
+ Outgoing messages sent outside the schedule window are queued and
+ delivered when the schedule opens according to your
+ configured send rate.
+
+
+
+
+
+ | Name |
+ Timezone |
+ Schedule |
+ Action |
+
+
+
+
+ |
+ {{ schedule.name }}
+ |
+
+ {{ schedule.timezone }}
+ |
+
+
+ {{ line[0] }}:
+ {{ line[1] }}
+
+ |
+
+
+ {{ mdiSquareEditOutline }}
+
+ Edit
+
+
+ |
+
+
+
+
+
+
+ {{ mdiCalendarClock }}
+ Create Send Schedule
+
+ Documentation
+
+
Email Notifications
@@ -415,7 +493,27 @@
{{ mdiContentSave }}
Save Notification Settings
-
+
+ Message Data Retention
+
+ Your messages are permanently deleted once they exceed the max
+ retention period below, counted from when the message was sent or
+ received. You can always delete your messages manually on the
+ message search page.
+
+
+
+
Delete Account
@@ -485,6 +583,7 @@
+
Edit Phone
@@ -547,8 +646,29 @@
dense
placeholder="How many retries when sending an SMS"
label="Max Send Attempts"
+ min="1"
+ max="5"
+ :rules="[
+ (v) =>
+ (v >= 1 && v <= 5) ||
+ 'Max send attempts must be between 1 and 5',
+ ]"
>
+
-
+
{{ mdiDelete }}
@@ -581,6 +706,7 @@
+
@@ -696,6 +822,7 @@
+
@@ -809,14 +936,213 @@
+
+
+
+
+ Create Message Send Schedule
+ Edit Message Send Schedule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
–
+
+
+
+
+
+ {{ mdiPlus }}
+
+
+ {{ mdiDelete }}
+
+
+
+
+ {{ scheduleWindowError(day.value) }}
+
+
+
+
+
+
+
+
+ Save Schedule
+
+
+
+ {{ mdiContentSave }}
+
+ Update Schedule
+
+
+
+
+ {{ mdiDelete }}
+
+ Delete
+
+
+ Close
+
+
+
+
+
+
+
+ Delete schedule
+
+ Are you sure you want to delete {{ activeSchedule.name }}? Phones attached to this schedule will no longer have schedule-based
+ restrictions.
+
+
+
+ Delete
+
+
+ Cancel
+
+
+
-
diff --git a/web/pages/threads/_id/index.vue b/web/pages/threads/_id/index.vue
index a9216a9f..fc0e27f3 100644
--- a/web/pages/threads/_id/index.vue
+++ b/web/pages/threads/_id/index.vue
@@ -162,6 +162,7 @@
:color="isMT(message) ? 'primary' : 'default'"
>
@@ -173,6 +174,23 @@
>
+
+
+
+ {{
+ mdiPaperclip
+ }}
+ {{ formatAttachmentName(attachment) }}
+
+
+
{{ new Date(message.order_timestamp).toLocaleString() }}
@@ -334,6 +352,7 @@ import {
mdiCheckAll,
mdiDelete,
mdiCallMissed,
+ mdiPaperclip,
mdiCheck,
mdiAlert,
mdiPackageUp,
@@ -362,6 +381,7 @@ export default Vue.extend({
mdiArrowLeft,
mdiCheckAll,
mdiCallMissed,
+ mdiPaperclip,
mdiCheck,
mdiAlert,
mdiDelete,
@@ -427,6 +447,11 @@ export default Vue.extend({
this.loadMessages(false)
}
})
+ this.webhookChannel.bind('message.phone.received', () => {
+ if (!this.loadingMessages) {
+ this.loadMessages(false)
+ }
+ })
},
beforeDestroy() {
@@ -436,6 +461,14 @@ export default Vue.extend({
},
methods: {
+ formatAttachmentName(url: string): string {
+ const parts = url.split('/')
+ if (parts.length >= 2) {
+ return '/' + parts.slice(-2).join('/')
+ }
+ return url
+ },
+
isPending(message: Message): boolean {
return ['sending', 'pending', 'scheduled'].includes(message.status)
},
@@ -623,6 +656,10 @@ export default Vue.extend({
}
}
+.hover\:text-decoration-underline:hover {
+ text-decoration: underline !important;
+}
+
.no-scrollbar,
.no-scrollbar textarea {
overflow-x: hidden;
diff --git a/web/plugins/filters.ts b/web/plugins/filters.ts
index 2a7c1a99..d4f3c942 100644
--- a/web/plugins/filters.ts
+++ b/web/plugins/filters.ts
@@ -45,12 +45,41 @@ Vue.filter('decimal', (value: string): string => {
})
Vue.filter('billingPeriod', (value: string): string => {
- const options = {
+ const date = new Date(value)
+ const options: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
year: 'numeric',
+ }
+ return date.toLocaleDateString('en-US', options)
+})
+
+Vue.filter('billingPeriodDate', (value: string): string => {
+ const date = new Date(value)
+ const options: Intl.DateTimeFormatOptions = {
+ day: 'numeric',
month: 'long',
+ year: 'numeric',
}
- // @ts-ignore
- return new Date(value).toLocaleDateString('en-US', options)
+ return date.toLocaleDateString('en-US', options)
+})
+
+Vue.filter('billingPeriodDateOrdinal', (value: string): string => {
+ const date = new Date(value)
+ const day = date.getDate()
+ const month = date.toLocaleDateString('en-US', { month: 'long' })
+ const year = date.getFullYear()
+
+ const suffix =
+ day % 10 === 1 && day !== 11
+ ? 'st'
+ : day % 10 === 2 && day !== 12
+ ? 'nd'
+ : day % 10 === 3 && day !== 13
+ ? 'rd'
+ : 'th'
+
+ return `${month} ${day}${suffix} ${year}`
})
Vue.filter('humanizeTime', (value: string): string => {
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index e6addfc8..7c02e1ce 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -16,25 +16,25 @@ importers:
version: 1.4.2
'@nuxtjs/firebase':
specifier: ^8.2.2
- version: 8.2.2(@firebase/app-types@0.9.2)(firebase@10.14.1)(nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16))
+ version: 8.2.2(@firebase/app-types@0.9.2)(firebase@10.14.1)(nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16))
'@nuxtjs/sitemap':
specifier: ^2.4.0
version: 2.4.0
chart.js:
- specifier: ^4.5.0
- version: 4.5.0
+ specifier: ^4.5.1
+ version: 4.5.1
chartjs-adapter-moment:
specifier: ^1.0.1
- version: 1.0.1(chart.js@4.5.0)(moment@2.30.1)
+ version: 1.0.1(chart.js@4.5.1)(moment@2.30.1)
core-js:
- specifier: ^3.45.1
- version: 3.45.1
+ specifier: ^3.49.0
+ version: 3.49.0
date-fns:
specifier: ^2.30.0
version: 2.30.0
dotenv:
- specifier: ^17.2.1
- version: 17.2.1
+ specifier: ^17.2.3
+ version: 17.2.3
firebase:
specifier: ^10.14.1
version: 10.14.1
@@ -42,17 +42,17 @@ importers:
specifier: ^6.1.0
version: 6.1.0(firebase@10.14.1)
jest-environment-jsdom:
- specifier: ^30.2.0
- version: 30.2.0
+ specifier: ^30.3.0
+ version: 30.3.0
libphonenumber-js:
- specifier: ^1.12.9
- version: 1.12.9
+ specifier: ^1.12.36
+ version: 1.12.36
moment:
specifier: ^2.30.1
version: 2.30.1
nuxt:
specifier: ^2.18.1
- version: 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)
+ version: 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)
nuxt-highlightjs:
specifier: ^1.0.3
version: 1.0.3
@@ -63,14 +63,14 @@ importers:
specifier: ^1.5.0
version: 1.5.4
ufo:
- specifier: ^1.6.1
- version: 1.6.1
+ specifier: ^1.6.4
+ version: 1.6.4
vue:
specifier: ^2.7.16
version: 2.7.16
vue-chartjs:
- specifier: ^5.3.2
- version: 5.3.2(chart.js@4.5.0)(vue@2.7.16)
+ specifier: ^5.3.3
+ version: 5.3.3(chart.js@4.5.1)(vue@2.7.16)
vue-class-component:
specifier: ^7.2.6
version: 7.2.6(vue@2.7.16)
@@ -96,45 +96,45 @@ importers:
specifier: ^3.6.2
version: 3.6.2(vue@2.7.16)
webpack:
- specifier: ^5.102.0
- version: 5.102.0
+ specifier: ^5.104.1
+ version: 5.104.1
devDependencies:
'@babel/eslint-parser':
- specifier: ^7.27.5
- version: 7.27.5(@babel/core@7.28.4)(eslint@8.57.1)
+ specifier: ^7.28.6
+ version: 7.28.6(@babel/core@7.28.4)(eslint@8.57.1)
'@commitlint/cli':
- specifier: ^20.1.0
- version: 20.1.0(@types/node@24.6.2)(typescript@4.9.5)
+ specifier: ^20.4.0
+ version: 20.4.0(@types/node@25.6.0)(typescript@4.9.5)
'@commitlint/config-conventional':
- specifier: ^19.8.0
- version: 19.8.0
+ specifier: ^20.5.3
+ version: 20.5.3
'@nuxt/types':
specifier: ^2.18.1
version: 2.18.1
'@nuxt/typescript-build':
specifier: ^3.0.2
- version: 3.0.2(@nuxt/types@2.18.1)(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.102.0)
+ version: 3.0.2(@nuxt/types@2.18.1)(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.104.1)
'@nuxtjs/eslint-config-typescript':
specifier: ^12.1.0
version: 12.1.0(eslint@8.57.1)(typescript@4.9.5)
'@nuxtjs/eslint-module':
specifier: ^4.1.0
- version: 4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))(webpack@5.102.0)
+ version: 4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
'@nuxtjs/stylelint-module':
specifier: ^5.2.0
- version: 5.2.0(postcss@8.4.39)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))(webpack@5.102.0)
+ version: 5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)
'@nuxtjs/vuetify':
specifier: ^1.12.3
- version: 1.12.3(vue@2.7.16)(webpack@5.102.0)
+ version: 1.12.3(vue@2.7.16)(webpack@5.104.1)
'@types/qrcode':
- specifier: ^1.5.5
- version: 1.5.5
+ specifier: ^1.5.6
+ version: 1.5.6
'@vue/test-utils':
specifier: ^1.3.6
version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16)
axios:
- specifier: ^0.30.2
- version: 0.30.2
+ specifier: ^0.32.0
+ version: 0.32.0
babel-core:
specifier: 7.0.0-bridge.0
version: 7.0.0-bridge.0(@babel/core@7.28.4)
@@ -158,7 +158,7 @@ importers:
version: 11.11.1
jest:
specifier: ^30.2.0
- version: 30.2.0(@types/node@24.6.2)
+ version: 30.2.0(@types/node@25.6.0)
lint-staged:
specifier: ^16.1.4
version: 16.1.4
@@ -166,11 +166,11 @@ importers:
specifier: ^1.6.7
version: 1.6.7
postcss-html:
- specifier: ^1.7.0
- version: 1.7.0
+ specifier: ^1.8.1
+ version: 1.8.1
prettier:
- specifier: 3.6.2
- version: 3.6.2
+ specifier: 3.8.1
+ version: 3.8.1
stylelint:
specifier: ^15.11.0
version: 15.11.0(typescript@4.9.5)
@@ -179,13 +179,13 @@ importers:
version: 9.0.5(stylelint@15.11.0(typescript@4.9.5))
stylelint-config-recommended-vue:
specifier: ^1.5.0
- version: 1.5.0(postcss-html@1.7.0)(stylelint@15.11.0(typescript@4.9.5))
+ version: 1.5.0(postcss-html@1.8.1)(stylelint@15.11.0(typescript@4.9.5))
stylelint-config-standard:
specifier: ^34.0.0
version: 34.0.0(stylelint@15.11.0(typescript@4.9.5))
ts-jest:
- specifier: ^29.4.4
- version: 29.4.4(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@24.6.2))(typescript@4.9.5)
+ specifier: ^29.4.6
+ version: 29.4.6(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.3.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.3.0)(jest@30.2.0(@types/node@25.6.0))(typescript@4.9.5)
vue-client-only:
specifier: ^2.1.0
version: 2.1.0
@@ -228,6 +228,10 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/compat-data@7.24.7':
resolution: {integrity: sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==}
engines: {node: '>=6.9.0'}
@@ -244,8 +248,8 @@ packages:
resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==}
engines: {node: '>=6.9.0'}
- '@babel/eslint-parser@7.27.5':
- resolution: {integrity: sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==}
+ '@babel/eslint-parser@7.28.6':
+ resolution: {integrity: sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==}
engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0}
peerDependencies:
'@babel/core': ^7.11.0
@@ -422,6 +426,10 @@ packages:
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-option@7.24.7':
resolution: {integrity: sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==}
engines: {node: '>=6.9.0'}
@@ -1012,77 +1020,77 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
- '@commitlint/cli@20.1.0':
- resolution: {integrity: sha512-pW5ujjrOovhq5RcYv5xCpb4GkZxkO2+GtOdBW2/qrr0Ll9tl3PX0aBBobGQl3mdZUbOBgwAexEQLeH6uxL0VYg==}
+ '@commitlint/cli@20.4.0':
+ resolution: {integrity: sha512-2lqrFrYNxjKxgMqeYiO3zNM14XN9v72/5xIJyvdLw7sHEGlfg6sweW01PGNWiqZa6/AuZwsb0uzkgWJy6F4N2w==}
engines: {node: '>=v18'}
hasBin: true
- '@commitlint/config-conventional@19.8.0':
- resolution: {integrity: sha512-9I2kKJwcAPwMoAj38hwqFXG0CzS2Kj+SAByPUQ0SlHTfb7VUhYVmo7G2w2tBrqmOf7PFd6MpZ/a1GQJo8na8kw==}
+ '@commitlint/config-conventional@20.5.3':
+ resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==}
engines: {node: '>=v18'}
- '@commitlint/config-validator@20.0.0':
- resolution: {integrity: sha512-BeyLMaRIJDdroJuYM2EGhDMGwVBMZna9UiIqV9hxj+J551Ctc6yoGuGSmghOy/qPhBSuhA6oMtbEiTmxECafsg==}
+ '@commitlint/config-validator@20.4.0':
+ resolution: {integrity: sha512-zShmKTF+sqyNOfAE0vKcqnpvVpG0YX8F9G/ZIQHI2CoKyK+PSdladXMSns400aZ5/QZs+0fN75B//3Q5CHw++w==}
engines: {node: '>=v18'}
- '@commitlint/ensure@20.0.0':
- resolution: {integrity: sha512-WBV47Fffvabe68n+13HJNFBqiMH5U1Ryls4W3ieGwPC0C7kJqp3OVQQzG2GXqOALmzrgAB+7GXmyy8N9ct8/Fg==}
+ '@commitlint/ensure@20.4.0':
+ resolution: {integrity: sha512-F3qwnanJUisFWwh44GYYmMOxfgJL1FKV73FCB5zxo8pw1CHkxXadGfDfzNkN8B3iqgSGusDN2+oDH6upBmLszA==}
engines: {node: '>=v18'}
'@commitlint/execute-rule@20.0.0':
resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==}
engines: {node: '>=v18'}
- '@commitlint/format@20.0.0':
- resolution: {integrity: sha512-zrZQXUcSDmQ4eGGrd+gFESiX0Rw+WFJk7nW4VFOmxub4mAATNKBQ4vNw5FgMCVehLUKG2OT2LjOqD0Hk8HvcRg==}
+ '@commitlint/format@20.4.0':
+ resolution: {integrity: sha512-i3ki3WR0rgolFVX6r64poBHXM1t8qlFel1G1eCBvVgntE3fCJitmzSvH5JD/KVJN/snz6TfaX2CLdON7+s4WVQ==}
engines: {node: '>=v18'}
- '@commitlint/is-ignored@20.0.0':
- resolution: {integrity: sha512-ayPLicsqqGAphYIQwh9LdAYOVAQ9Oe5QCgTNTj+BfxZb9b/JW222V5taPoIBzYnAP0z9EfUtljgBk+0BN4T4Cw==}
+ '@commitlint/is-ignored@20.4.0':
+ resolution: {integrity: sha512-E8AHpedEfuf+lZatFvFiJXA4TtZgBZ10+A7HzFudaEmTPPE5o6MGswxbxUIGAciaHAFj/oTTmyFc6A5tcvxE3Q==}
engines: {node: '>=v18'}
- '@commitlint/lint@20.0.0':
- resolution: {integrity: sha512-kWrX8SfWk4+4nCexfLaQT3f3EcNjJwJBsSZ5rMBw6JCd6OzXufFHgel2Curos4LKIxwec9WSvs2YUD87rXlxNQ==}
+ '@commitlint/lint@20.4.0':
+ resolution: {integrity: sha512-W90YCbm5h3Yg+btF5/X+cxsY6vd/H3tsFt6U7WBmDQSkKV8NmitYg89zeoSQyYEiQCwAsH0dcA+99aQtLZiSnw==}
engines: {node: '>=v18'}
- '@commitlint/load@20.1.0':
- resolution: {integrity: sha512-qo9ER0XiAimATQR5QhvvzePfeDfApi/AFlC1G+YN+ZAY8/Ua6IRrDrxRvQAr+YXUKAxUsTDSp9KXeXLBPsNRWg==}
+ '@commitlint/load@20.4.0':
+ resolution: {integrity: sha512-Dauup/GfjwffBXRJUdlX/YRKfSVXsXZLnINXKz0VZkXdKDcaEILAi9oflHGbfydonJnJAbXEbF3nXPm9rm3G6A==}
engines: {node: '>=v18'}
- '@commitlint/message@20.0.0':
- resolution: {integrity: sha512-gLX4YmKnZqSwkmSB9OckQUrI5VyXEYiv3J5JKZRxIp8jOQsWjZgHSG/OgEfMQBK9ibdclEdAyIPYggwXoFGXjQ==}
+ '@commitlint/message@20.4.0':
+ resolution: {integrity: sha512-B5lGtvHgiLAIsK5nLINzVW0bN5hXv+EW35sKhYHE8F7V9Uz1fR4tx3wt7mobA5UNhZKUNgB/+ldVMQE6IHZRyA==}
engines: {node: '>=v18'}
- '@commitlint/parse@20.0.0':
- resolution: {integrity: sha512-j/PHCDX2bGM5xGcWObOvpOc54cXjn9g6xScXzAeOLwTsScaL4Y+qd0pFC6HBwTtrH92NvJQc+2Lx9HFkVi48cg==}
+ '@commitlint/parse@20.4.0':
+ resolution: {integrity: sha512-NcRkqo/QUnuc1RgxRCIKTqobKzF0BKJ8h3i1jRyeZ+SEy5rO9dPNOh4BqrFsSznb5mnwETYB7ph9tUcthNkwAQ==}
engines: {node: '>=v18'}
- '@commitlint/read@20.0.0':
- resolution: {integrity: sha512-Ti7Y7aEgxsM1nkwA4ZIJczkTFRX/+USMjNrL9NXwWQHqNqrBX2iMi+zfuzZXqfZ327WXBjdkRaytJ+z5vNqTOA==}
+ '@commitlint/read@20.4.0':
+ resolution: {integrity: sha512-QfpFn6/I240ySEGv7YWqho4vxqtPpx40FS7kZZDjUJ+eHxu3azfhy7fFb5XzfTqVNp1hNoI3tEmiEPbDB44+cg==}
engines: {node: '>=v18'}
- '@commitlint/resolve-extends@20.1.0':
- resolution: {integrity: sha512-cxKXQrqHjZT3o+XPdqDCwOWVFQiae++uwd9dUBC7f2MdV58ons3uUvASdW7m55eat5sRiQ6xUHyMWMRm6atZWw==}
+ '@commitlint/resolve-extends@20.4.0':
+ resolution: {integrity: sha512-ay1KM8q0t+/OnlpqXJ+7gEFQNlUtSU5Gxr8GEwnVf2TPN3+ywc5DzL3JCxmpucqxfHBTFwfRMXxPRRnR5Ki20g==}
engines: {node: '>=v18'}
- '@commitlint/rules@20.0.0':
- resolution: {integrity: sha512-gvg2k10I/RfvHn5I5sxvVZKM1fl72Sqrv2YY/BnM7lMHcYqO0E2jnRWoYguvBfEcZ39t+rbATlciggVe77E4zA==}
+ '@commitlint/rules@20.4.0':
+ resolution: {integrity: sha512-E+UoAA7WA4xrre9lDyX2vL4Df26I+vqMN4D8JoW/L2xE/VRDvn533/ibhgSlGYDltB9nm2S+1lti3PagEwO0ag==}
engines: {node: '>=v18'}
'@commitlint/to-lines@20.0.0':
resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==}
engines: {node: '>=v18'}
- '@commitlint/top-level@20.0.0':
- resolution: {integrity: sha512-drXaPSP2EcopukrUXvUXmsQMu3Ey/FuJDc/5oiW4heoCfoE5BdLQyuc7veGeE3aoQaTVqZnh4D5WTWe2vefYKg==}
+ '@commitlint/top-level@20.4.0':
+ resolution: {integrity: sha512-NDzq8Q6jmFaIIBC/GG6n1OQEaHdmaAAYdrZRlMgW6glYWGZ+IeuXmiymDvQNXPc82mVxq2KiE3RVpcs+1OeDeA==}
engines: {node: '>=v18'}
- '@commitlint/types@19.8.0':
- resolution: {integrity: sha512-LRjP623jPyf3Poyfb0ohMj8I3ORyBDOwXAgxxVPbSD0unJuW2mJWeiRfaQinjtccMqC5Wy1HOMfa4btKjbNxbg==}
+ '@commitlint/types@20.4.0':
+ resolution: {integrity: sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==}
engines: {node: '>=v18'}
- '@commitlint/types@20.0.0':
- resolution: {integrity: sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA==}
+ '@commitlint/types@20.5.0':
+ resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==}
engines: {node: '>=v18'}
'@csstools/cascade-layer-name-parser@1.0.12':
@@ -1854,8 +1862,8 @@ packages:
resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
- '@jest/environment-jsdom-abstract@30.2.0':
- resolution: {integrity: sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==}
+ '@jest/environment-jsdom-abstract@30.3.0':
+ resolution: {integrity: sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
@@ -1868,6 +1876,10 @@ packages:
resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ '@jest/environment@30.3.0':
+ resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
'@jest/expect-utils@30.2.0':
resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1880,6 +1892,10 @@ packages:
resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ '@jest/fake-timers@30.3.0':
+ resolution: {integrity: sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
'@jest/get-type@30.1.0':
resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1937,6 +1953,10 @@ packages:
resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ '@jest/types@30.3.0':
+ resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -2188,6 +2208,9 @@ packages:
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+ '@protobufjs/codegen@2.0.5':
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
@@ -2200,6 +2223,9 @@ packages:
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+ '@protobufjs/inquire@1.1.1':
+ resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
+
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
@@ -2209,6 +2235,9 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+ '@protobufjs/utf8@1.1.1':
+ resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
+
'@rollup/pluginutils@4.2.1':
resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
engines: {node: '>= 8.0.0'}
@@ -2231,6 +2260,10 @@ packages:
rollup:
optional: true
+ '@simple-libs/stream-utils@1.2.0':
+ resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
+ engines: {node: '>=18'}
+
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -2247,6 +2280,9 @@ packages:
'@sinonjs/fake-timers@13.0.5':
resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
+ '@sinonjs/fake-timers@15.3.2':
+ resolution: {integrity: sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==}
+
'@tootallnate/once@2.0.0':
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -2279,9 +2315,6 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
- '@types/conventional-commits-parser@5.0.1':
- resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
-
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -2354,11 +2387,11 @@ packages:
'@types/node@16.18.55':
resolution: {integrity: sha512-Y1zz/LIuJek01+hlPNzzXQhmq/Z2BCP96j18MSXC0S0jSu/IG4FFxmBs7W4/lI2vPJ7foVfEB0hUVtnOjnCiTg==}
- '@types/node@20.8.0':
- resolution: {integrity: sha512-LzcWltT83s1bthcvjBmiBvGJiiUe84NWRHkw+ZV6Fr41z2FbIzvc815dk2nQ3RAKMuN2fkenM/z3Xv2QzEpYxQ==}
+ '@types/node@25.1.0':
+ resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
- '@types/node@24.6.2':
- resolution: {integrity: sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==}
+ '@types/node@25.6.0':
+ resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/normalize-package-data@2.4.2':
resolution: {integrity: sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A==}
@@ -2372,8 +2405,8 @@ packages:
'@types/pug@2.0.10':
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
- '@types/qrcode@1.5.5':
- resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.9.8':
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
@@ -2435,6 +2468,9 @@ packages:
'@types/yargs@17.0.33':
resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==}
+ '@types/yargs@17.0.35':
+ resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
+
'@typescript-eslint/eslint-plugin@6.7.3':
resolution: {integrity: sha512-vntq452UHNltxsaaN+L9WyuMch8bMd9CqJ3zhzTPXXidwbf5mqqKCVXEuvRZUqLJSTLeWE65lQwyXsRGnXkCTA==}
engines: {node: ^16.0.0 || >=18.0.0}
@@ -2495,9 +2531,11 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -2538,41 +2576,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2758,10 +2804,6 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
- JSONStream@1.3.5:
- resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
- hasBin: true
-
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
@@ -3020,8 +3062,8 @@ packages:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
- axios@0.30.2:
- resolution: {integrity: sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==}
+ axios@0.32.0:
+ resolution: {integrity: sha512-sGQArzERW2SI8IRkjuJ5y91Sm9QjiRq4Ay4kOLqpbBt5CeKDNq4g6nirJdyD+palK3yEDXnJiVXsesX66AjwyA==}
babel-code-frame@6.26.0:
resolution: {integrity: sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==}
@@ -3116,12 +3158,8 @@ packages:
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
engines: {node: '>=0.10.0'}
- baseline-browser-mapping@2.8.10:
- resolution: {integrity: sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==}
- hasBin: true
-
- baseline-browser-mapping@2.8.9:
- resolution: {integrity: sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==}
+ baseline-browser-mapping@2.9.11:
+ resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
big.js@5.2.2:
@@ -3166,6 +3204,9 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+ brace-expansion@2.1.1:
+ resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
+
braces@2.3.2:
resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==}
engines: {node: '>=0.10.0'}
@@ -3200,13 +3241,8 @@ packages:
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
- browserslist@4.26.2:
- resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==}
- engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
-
- browserslist@4.26.3:
- resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==}
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3309,8 +3345,8 @@ packages:
caniuse-lite@1.0.30001639:
resolution: {integrity: sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg==}
- caniuse-lite@1.0.30001746:
- resolution: {integrity: sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==}
+ caniuse-lite@1.0.30001762:
+ resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
chalk@1.1.3:
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
@@ -3328,10 +3364,6 @@ packages:
resolution: {integrity: sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
- chalk@5.6.2:
- resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
- engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
-
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
@@ -3339,8 +3371,8 @@ packages:
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
- chart.js@4.5.0:
- resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
+ chart.js@4.5.1:
+ resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chartjs-adapter-moment@1.0.1:
@@ -3384,6 +3416,10 @@ packages:
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
engines: {node: '>=8'}
+ ci-info@4.4.0:
+ resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
+ engines: {node: '>=8'}
+
cipher-base@1.0.4:
resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==}
@@ -3727,17 +3763,22 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
- conventional-changelog-angular@7.0.0:
- resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==}
- engines: {node: '>=16'}
+ conventional-changelog-angular@8.1.0:
+ resolution: {integrity: sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==}
+ engines: {node: '>=18'}
- conventional-changelog-conventionalcommits@7.0.2:
- resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==}
- engines: {node: '>=16'}
+ conventional-changelog-conventionalcommits@9.3.1:
+ resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==}
+ engines: {node: '>=18'}
- conventional-commits-parser@5.0.0:
- resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==}
- engines: {node: '>=16'}
+ conventional-commits-parser@6.2.1:
+ resolution: {integrity: sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ conventional-commits-parser@6.4.0:
+ resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==}
+ engines: {node: '>=18'}
hasBin: true
convert-source-map@2.0.0:
@@ -3762,14 +3803,14 @@ packages:
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
- core-js@3.45.1:
- resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==}
+ core-js@3.49.0:
+ resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
- cosmiconfig-typescript-loader@6.1.0:
- resolution: {integrity: sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==}
+ cosmiconfig-typescript-loader@6.2.0:
+ resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==}
engines: {node: '>=v18'}
peerDependencies:
'@types/node': '*'
@@ -4184,8 +4225,8 @@ packages:
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
- domutils@3.1.0:
- resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -4198,8 +4239,8 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
- dotenv@17.2.1:
- resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
+ dotenv@17.2.3:
+ resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dotenv@8.6.0:
@@ -4242,11 +4283,8 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
- electron-to-chromium@1.5.227:
- resolution: {integrity: sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==}
-
- electron-to-chromium@1.5.228:
- resolution: {integrity: sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==}
+ electron-to-chromium@1.5.267:
+ resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
elliptic@6.6.0:
resolution: {integrity: sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==}
@@ -4283,8 +4321,8 @@ packages:
resolution: {integrity: sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==}
engines: {node: '>=6.9.0'}
- enhanced-resolve@5.18.3:
- resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
+ enhanced-resolve@5.18.4:
+ resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
ent@2.2.0:
@@ -4334,11 +4372,11 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- es-module-lexer@1.7.0:
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
- es-object-atoms@1.1.1:
- resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
@@ -4703,8 +4741,8 @@ packages:
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
- filelist@1.0.4:
- resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
+ filelist@1.0.6:
+ resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
fill-range@4.0.0:
resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==}
@@ -4746,10 +4784,6 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
- find-up@7.0.0:
- resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
- engines: {node: '>=18'}
-
firebase-admin@10.3.0:
resolution: {integrity: sha512-A0wgMLEjyVyUE+heyMJYqHRkPVjpebhOYsa47RHdrTM4ltApcx8Tn86sUmjqxlfh09gNnILAm7a8q5+FmgBYpg==}
engines: {node: '>=12.7.0'}
@@ -4776,8 +4810,8 @@ packages:
flush-write-stream@1.1.1:
resolution: {integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==}
- follow-redirects@1.15.11:
- resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -4810,8 +4844,8 @@ packages:
vue-template-compiler:
optional: true
- form-data@4.0.4:
- resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
+ form-data@4.0.5:
+ resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
@@ -4956,6 +4990,7 @@ packages:
git-raw-commits@4.0.0:
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
engines: {node: '>=16'}
+ deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.
hasBin: true
git-up@7.0.0:
@@ -4980,16 +5015,17 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
@@ -5155,8 +5191,8 @@ packages:
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
- hasown@2.0.2:
- resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
he@1.2.0:
@@ -5492,6 +5528,10 @@ packages:
resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
engines: {node: '>=0.10.0'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-plain-object@2.0.4:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
@@ -5532,10 +5572,6 @@ packages:
resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==}
engines: {node: '>= 0.4'}
- is-text-path@2.0.0:
- resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==}
- engines: {node: '>=8'}
-
is-typed-array@1.1.12:
resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==}
engines: {node: '>= 0.4'}
@@ -5648,8 +5684,8 @@ packages:
resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
- jest-environment-jsdom@30.2.0:
- resolution: {integrity: sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==}
+ jest-environment-jsdom@30.3.0:
+ resolution: {integrity: sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
@@ -5677,10 +5713,18 @@ packages:
resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-message-util@30.3.0:
+ resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
jest-mock@30.2.0:
resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-mock@30.3.0:
+ resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
jest-pnp-resolver@1.2.3:
resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
engines: {node: '>=6'}
@@ -5722,6 +5766,10 @@ packages:
resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-util@30.3.0:
+ resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
jest-validate@30.2.0:
resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5787,8 +5835,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-tokens@9.0.0:
- resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==}
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
@@ -5798,6 +5846,10 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
jsdom@26.1.0:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
@@ -5861,10 +5913,6 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
- jsonparse@1.3.1:
- resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
- engines: {'0': node >= 0.2.0}
-
jsonwebtoken@8.5.1:
resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==}
engines: {node: '>=4', npm: '>=1.4.28'}
@@ -5885,6 +5933,9 @@ packages:
jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
+ kasi@2.0.1:
+ resolution: {integrity: sha512-8qhiHZ1BN26ig1+jQ9fWEk6dj8T1wuxs00QRJfXIANI4scto1EuPUgqj+mxHls52WBfdTNJGQ8yYw9rDpWUcgQ==}
+
keyv@4.5.3:
resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==}
@@ -5934,8 +5985,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
- libphonenumber-js@1.12.9:
- resolution: {integrity: sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==}
+ libphonenumber-js@1.12.36:
+ resolution: {integrity: sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
@@ -5964,8 +6015,8 @@ packages:
resolution: {integrity: sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==}
engines: {node: '>=4.3.0 <5.0.0 || >=5.10'}
- loader-runner@4.3.0:
- resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
+ loader-runner@4.3.1:
+ resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
loader-utils@1.4.2:
@@ -5996,10 +6047,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
- locate-path@7.2.0:
- resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
lodash._reinterpolate@3.0.0:
resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
@@ -6045,12 +6092,6 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
- lodash.snakecase@4.1.1:
- resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
-
- lodash.startcase@4.4.0:
- resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
-
lodash.template@4.5.0:
resolution: {integrity: sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==}
deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead.
@@ -6067,9 +6108,6 @@ packages:
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
- lodash.upperfirst@4.3.1:
- resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==}
-
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -6203,6 +6241,10 @@ packages:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'}
+ meow@13.2.0:
+ resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
+ engines: {node: '>=18'}
+
merge-source-map@1.1.0:
resolution: {integrity: sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==}
@@ -6288,6 +6330,10 @@ packages:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
+ minimatch@5.1.9:
+ resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
+ engines: {node: '>=10'}
+
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6401,6 +6447,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6464,8 +6515,8 @@ packages:
resolution: {integrity: sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==}
engines: {node: '>=0.10.0'}
- node-releases@2.0.21:
- resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
node-res@5.0.1:
resolution: {integrity: sha512-YOleO9c7MAqoHC+Ccu2vzvV1fL6Ku49gShq3PIMKWHRgrMSih3XcwL05NbLBi6oU2J471gTBfdpVVxwT6Pfhxg==}
@@ -6521,8 +6572,8 @@ packages:
deprecated: Nuxt 2 has reached EOL and is no longer actively maintained. See https://nuxt.com/blog/nuxt2-eol for more details.
hasBin: true
- nwsapi@2.2.22:
- resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
+ nwsapi@2.2.23:
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
nypm@0.3.9:
resolution: {integrity: sha512-BI2SdqqTHg2d4wJh8P9A1W+bslg33vOE9IZDY6eR2QC+Pu1iNBVZUqczrd43rJb+fMzHU7ltAYKsEFY/kHMFcw==}
@@ -6636,10 +6687,6 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
- p-limit@4.0.0:
- resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
@@ -6652,10 +6699,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
- p-locate@6.0.0:
- resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
@@ -6729,10 +6772,6 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
- path-exists@5.0.0:
- resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@@ -6779,9 +6818,6 @@ packages:
picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
- picocolors@1.0.1:
- resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
-
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -6789,10 +6825,18 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pidtree@0.6.0:
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
engines: {node: '>=0.10'}
@@ -7014,8 +7058,8 @@ packages:
peerDependencies:
postcss: ^8.4
- postcss-html@1.7.0:
- resolution: {integrity: sha512-MfcMpSUIaR/nNgeVS8AyvyDugXlADjN9AcV7e5rDfrF1wduIAGSkL4q2+wgrZgA3sHVAHLDO9FuauHhZYW2nBw==}
+ postcss-html@1.8.1:
+ resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==}
engines: {node: ^12 || >=14}
postcss-image-set-function@6.0.3:
@@ -7402,12 +7446,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
- postcss@8.4.35:
- resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
- engines: {node: ^10 || ^12 || >=14}
-
- postcss@8.4.39:
- resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==}
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
@@ -7427,8 +7467,8 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
- prettier@3.6.2:
- resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
+ prettier@3.8.1:
+ resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
@@ -7443,6 +7483,10 @@ packages:
resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ pretty-format@30.3.0:
+ resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
pretty-time@1.1.0:
resolution: {integrity: sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==}
engines: {node: '>=4'}
@@ -7479,12 +7523,12 @@ packages:
resolution: {integrity: sha512-xL96WDdCZYdU7Slin569tFX712BxsxslWwAfAhCYjQKGTq7dAU91Lomy6nLLhh/dyGhk/YH4TwTSRxTzhuHyZg==}
hasBin: true
- protobufjs@6.11.4:
- resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==}
+ protobufjs@6.11.6:
+ resolution: {integrity: sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==}
hasBin: true
- protobufjs@7.2.5:
- resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==}
+ protobufjs@7.5.8:
+ resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==}
engines: {node: '>=12.0.0'}
protocols@2.0.1:
@@ -7753,8 +7797,8 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
- rollup@3.29.5:
- resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==}
+ rollup@3.30.0:
+ resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
@@ -7841,8 +7885,8 @@ packages:
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
engines: {node: '>= 10.13.0'}
- schema-utils@4.3.2:
- resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==}
+ schema-utils@4.3.3:
+ resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
scule@0.2.1:
@@ -7872,6 +7916,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
send@0.19.0:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
@@ -8000,10 +8049,6 @@ packages:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
- source-map-js@1.2.0:
- resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
- engines: {node: '>=0.10.0'}
-
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -8327,13 +8372,14 @@ packages:
resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==}
engines: {node: '>=6'}
- tapable@2.2.3:
- resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar@6.2.0:
resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
engines: {node: '>=10'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
teeny-request@7.2.0:
resolution: {integrity: sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw==}
@@ -8351,8 +8397,8 @@ packages:
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
- terser-webpack-plugin@5.3.14:
- resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==}
+ terser-webpack-plugin@5.3.16:
+ resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@swc/core': '*'
@@ -8372,8 +8418,8 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- terser@5.44.0:
- resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==}
+ terser@5.44.1:
+ resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
engines: {node: '>=10'}
hasBin: true
@@ -8384,10 +8430,6 @@ packages:
text-decoding@1.0.0:
resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==}
- text-extensions@2.4.0:
- resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
- engines: {node: '>=8'}
-
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -8418,8 +8460,9 @@ packages:
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==}
engines: {node: '>=0.6.0'}
- tinyexec@1.0.1:
- resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
+ tinyexec@1.0.2:
+ resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
+ engines: {node: '>=18'}
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
@@ -8493,8 +8536,8 @@ packages:
peerDependencies:
typescript: '>=4.2.0'
- ts-jest@29.4.4:
- resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==}
+ ts-jest@29.4.6:
+ resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -8618,8 +8661,8 @@ packages:
ua-parser-js@1.0.38:
resolution: {integrity: sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==}
- ufo@1.6.1:
- resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
+ ufo@1.6.4:
+ resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
@@ -8635,8 +8678,11 @@ packages:
unctx@2.3.1:
resolution: {integrity: sha512-PhKke8ZYauiqh3FEMVNm7ljvzQiph0Mt3GBRve03IJm7ukfaON2OBK795tLwhbyfzknuRRkW0+Ze+CQUmzOZ+A==}
- undici-types@7.13.0:
- resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ undici-types@7.19.2:
+ resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
undici@6.19.7:
resolution: {integrity: sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==}
@@ -8727,8 +8773,8 @@ packages:
resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
engines: {node: '>=4'}
- update-browserslist-db@1.1.3:
- resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -8778,6 +8824,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-to-istanbul@9.3.0:
@@ -8845,8 +8892,8 @@ packages:
vm-browserify@1.1.2:
resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
- vue-chartjs@5.3.2:
- resolution: {integrity: sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==}
+ vue-chartjs@5.3.3:
+ resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
@@ -8977,8 +9024,8 @@ packages:
watchpack@1.7.5:
resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==}
- watchpack@2.4.4:
- resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
+ watchpack@2.5.0:
+ resolution: {integrity: sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==}
engines: {node: '>=10.13.0'}
webidl-conversions@3.0.1:
@@ -9032,8 +9079,8 @@ packages:
webpack-command:
optional: true
- webpack@5.102.0:
- resolution: {integrity: sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==}
+ webpack@5.104.1:
+ resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==}
engines: {node: '>=10.13.0'}
hasBin: true
peerDependencies:
@@ -9059,6 +9106,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
@@ -9145,8 +9193,8 @@ packages:
utf-8-validate:
optional: true
- ws@8.18.3:
- resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+ ws@8.20.0:
+ resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -9236,10 +9284,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- yocto-queue@1.2.1:
- resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
- engines: {node: '>=12.20'}
-
snapshots:
'@aashutoshrathi/word-wrap@1.2.6': {}
@@ -9278,6 +9322,12 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/compat-data@7.24.7': {}
'@babel/compat-data@7.28.4': {}
@@ -9322,7 +9372,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/eslint-parser@7.27.5(@babel/core@7.28.4)(eslint@8.57.1)':
+ '@babel/eslint-parser@7.28.6(@babel/core@7.28.4)(eslint@8.57.1)':
dependencies:
'@babel/core': 7.28.4
'@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1
@@ -9364,7 +9414,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.24.7
'@babel/helper-validator-option': 7.24.7
- browserslist: 4.26.2
+ browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
@@ -9372,7 +9422,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.28.4
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.26.3
+ browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
@@ -9572,6 +9622,8 @@ snapshots:
'@babel/helper-validator-identifier@7.27.1': {}
+ '@babel/helper-validator-identifier@7.28.5': {}
+
'@babel/helper-validator-option@7.24.7': {}
'@babel/helper-validator-option@7.27.1': {}
@@ -10372,120 +10424,115 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
- '@commitlint/cli@20.1.0(@types/node@24.6.2)(typescript@4.9.5)':
+ '@commitlint/cli@20.4.0(@types/node@25.6.0)(typescript@4.9.5)':
dependencies:
- '@commitlint/format': 20.0.0
- '@commitlint/lint': 20.0.0
- '@commitlint/load': 20.1.0(@types/node@24.6.2)(typescript@4.9.5)
- '@commitlint/read': 20.0.0
- '@commitlint/types': 20.0.0
- tinyexec: 1.0.1
+ '@commitlint/format': 20.4.0
+ '@commitlint/lint': 20.4.0
+ '@commitlint/load': 20.4.0(@types/node@25.6.0)(typescript@4.9.5)
+ '@commitlint/read': 20.4.0
+ '@commitlint/types': 20.4.0
+ tinyexec: 1.0.2
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- typescript
- '@commitlint/config-conventional@19.8.0':
+ '@commitlint/config-conventional@20.5.3':
dependencies:
- '@commitlint/types': 19.8.0
- conventional-changelog-conventionalcommits: 7.0.2
+ '@commitlint/types': 20.5.0
+ conventional-changelog-conventionalcommits: 9.3.1
- '@commitlint/config-validator@20.0.0':
+ '@commitlint/config-validator@20.4.0':
dependencies:
- '@commitlint/types': 20.0.0
+ '@commitlint/types': 20.5.0
ajv: 8.17.1
- '@commitlint/ensure@20.0.0':
+ '@commitlint/ensure@20.4.0':
dependencies:
- '@commitlint/types': 20.0.0
- lodash.camelcase: 4.3.0
- lodash.kebabcase: 4.1.1
- lodash.snakecase: 4.1.1
- lodash.startcase: 4.4.0
- lodash.upperfirst: 4.3.1
+ '@commitlint/types': 20.5.0
+ kasi: 2.0.1
'@commitlint/execute-rule@20.0.0': {}
- '@commitlint/format@20.0.0':
+ '@commitlint/format@20.4.0':
dependencies:
- '@commitlint/types': 20.0.0
- chalk: 5.6.2
+ '@commitlint/types': 20.4.0
+ picocolors: 1.1.1
- '@commitlint/is-ignored@20.0.0':
+ '@commitlint/is-ignored@20.4.0':
dependencies:
- '@commitlint/types': 20.0.0
- semver: 7.7.2
+ '@commitlint/types': 20.5.0
+ semver: 7.7.3
- '@commitlint/lint@20.0.0':
+ '@commitlint/lint@20.4.0':
dependencies:
- '@commitlint/is-ignored': 20.0.0
- '@commitlint/parse': 20.0.0
- '@commitlint/rules': 20.0.0
- '@commitlint/types': 20.0.0
+ '@commitlint/is-ignored': 20.4.0
+ '@commitlint/parse': 20.4.0
+ '@commitlint/rules': 20.4.0
+ '@commitlint/types': 20.4.0
- '@commitlint/load@20.1.0(@types/node@24.6.2)(typescript@4.9.5)':
+ '@commitlint/load@20.4.0(@types/node@25.6.0)(typescript@4.9.5)':
dependencies:
- '@commitlint/config-validator': 20.0.0
+ '@commitlint/config-validator': 20.4.0
'@commitlint/execute-rule': 20.0.0
- '@commitlint/resolve-extends': 20.1.0
- '@commitlint/types': 20.0.0
- chalk: 5.6.2
+ '@commitlint/resolve-extends': 20.4.0
+ '@commitlint/types': 20.4.0
cosmiconfig: 9.0.0(typescript@4.9.5)
- cosmiconfig-typescript-loader: 6.1.0(@types/node@24.6.2)(cosmiconfig@9.0.0(typescript@4.9.5))(typescript@4.9.5)
- lodash.isplainobject: 4.0.6
- lodash.merge: 4.6.2
- lodash.uniq: 4.5.0
+ cosmiconfig-typescript-loader: 6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.0(typescript@4.9.5))(typescript@4.9.5)
+ is-plain-obj: 4.1.0
+ lodash.mergewith: 4.6.2
+ picocolors: 1.1.1
transitivePeerDependencies:
- '@types/node'
- typescript
- '@commitlint/message@20.0.0': {}
+ '@commitlint/message@20.4.0': {}
- '@commitlint/parse@20.0.0':
+ '@commitlint/parse@20.4.0':
dependencies:
- '@commitlint/types': 20.0.0
- conventional-changelog-angular: 7.0.0
- conventional-commits-parser: 5.0.0
+ '@commitlint/types': 20.5.0
+ conventional-changelog-angular: 8.1.0
+ conventional-commits-parser: 6.2.1
- '@commitlint/read@20.0.0':
+ '@commitlint/read@20.4.0':
dependencies:
- '@commitlint/top-level': 20.0.0
- '@commitlint/types': 20.0.0
+ '@commitlint/top-level': 20.4.0
+ '@commitlint/types': 20.4.0
git-raw-commits: 4.0.0
minimist: 1.2.8
- tinyexec: 1.0.1
+ tinyexec: 1.0.2
- '@commitlint/resolve-extends@20.1.0':
+ '@commitlint/resolve-extends@20.4.0':
dependencies:
- '@commitlint/config-validator': 20.0.0
- '@commitlint/types': 20.0.0
+ '@commitlint/config-validator': 20.4.0
+ '@commitlint/types': 20.5.0
global-directory: 4.0.1
import-meta-resolve: 4.2.0
lodash.mergewith: 4.6.2
resolve-from: 5.0.0
- '@commitlint/rules@20.0.0':
+ '@commitlint/rules@20.4.0':
dependencies:
- '@commitlint/ensure': 20.0.0
- '@commitlint/message': 20.0.0
+ '@commitlint/ensure': 20.4.0
+ '@commitlint/message': 20.4.0
'@commitlint/to-lines': 20.0.0
- '@commitlint/types': 20.0.0
+ '@commitlint/types': 20.5.0
'@commitlint/to-lines@20.0.0': {}
- '@commitlint/top-level@20.0.0':
+ '@commitlint/top-level@20.4.0':
dependencies:
- find-up: 7.0.0
+ escalade: 3.2.0
- '@commitlint/types@19.8.0':
+ '@commitlint/types@20.4.0':
dependencies:
- '@types/conventional-commits-parser': 5.0.1
- chalk: 5.5.0
+ conventional-commits-parser: 6.2.1
+ picocolors: 1.1.1
- '@commitlint/types@20.0.0':
+ '@commitlint/types@20.5.0':
dependencies:
- '@types/conventional-commits-parser': 5.0.1
- chalk: 5.6.2
+ conventional-commits-parser: 6.4.0
+ picocolors: 1.1.1
'@csstools/cascade-layer-name-parser@1.0.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)':
dependencies:
@@ -10553,193 +10600,193 @@ snapshots:
'@csstools/css-parser-algorithms': 2.3.2(@csstools/css-tokenizer@2.2.1)
'@csstools/css-tokenizer': 2.2.1
- '@csstools/postcss-cascade-layers@4.0.6(postcss@8.4.39)':
+ '@csstools/postcss-cascade-layers@4.0.6(postcss@8.5.6)':
dependencies:
'@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2)
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- '@csstools/postcss-color-function@3.0.17(postcss@8.4.39)':
+ '@csstools/postcss-color-function@3.0.17(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-color-mix-function@2.0.17(postcss@8.4.39)':
+ '@csstools/postcss-color-mix-function@2.0.17(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-exponential-functions@1.0.8(postcss@8.4.39)':
+ '@csstools/postcss-exponential-functions@1.0.8(postcss@8.5.6)':
dependencies:
'@csstools/css-calc': 1.2.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-font-format-keywords@3.0.2(postcss@8.4.39)':
+ '@csstools/postcss-font-format-keywords@3.0.2(postcss@8.5.6)':
dependencies:
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-gamut-mapping@1.0.10(postcss@8.4.39)':
+ '@csstools/postcss-gamut-mapping@1.0.10(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-gradients-interpolation-method@4.0.18(postcss@8.4.39)':
+ '@csstools/postcss-gradients-interpolation-method@4.0.18(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-hwb-function@3.0.16(postcss@8.4.39)':
+ '@csstools/postcss-hwb-function@3.0.16(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-ic-unit@3.0.6(postcss@8.4.39)':
+ '@csstools/postcss-ic-unit@3.0.6(postcss@8.5.6)':
dependencies:
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-initial@1.0.1(postcss@8.4.39)':
+ '@csstools/postcss-initial@1.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-is-pseudo-class@4.0.8(postcss@8.4.39)':
+ '@csstools/postcss-is-pseudo-class@4.0.8(postcss@8.5.6)':
dependencies:
'@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2)
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- '@csstools/postcss-light-dark-function@1.0.6(postcss@8.4.39)':
+ '@csstools/postcss-light-dark-function@1.0.6(postcss@8.5.6)':
dependencies:
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-logical-float-and-clear@2.0.1(postcss@8.4.39)':
+ '@csstools/postcss-logical-float-and-clear@2.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-logical-overflow@1.0.1(postcss@8.4.39)':
+ '@csstools/postcss-logical-overflow@1.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-logical-overscroll-behavior@1.0.1(postcss@8.4.39)':
+ '@csstools/postcss-logical-overscroll-behavior@1.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-logical-resize@2.0.1(postcss@8.4.39)':
+ '@csstools/postcss-logical-resize@2.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-logical-viewport-units@2.0.10(postcss@8.4.39)':
+ '@csstools/postcss-logical-viewport-units@2.0.10(postcss@8.5.6)':
dependencies:
'@csstools/css-tokenizer': 2.3.2
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-media-minmax@1.1.7(postcss@8.4.39)':
+ '@csstools/postcss-media-minmax@1.1.7(postcss@8.5.6)':
dependencies:
'@csstools/css-calc': 1.2.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
'@csstools/media-query-list-parser': 2.1.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-media-queries-aspect-ratio-number-values@2.0.10(postcss@8.4.39)':
+ '@csstools/postcss-media-queries-aspect-ratio-number-values@2.0.10(postcss@8.5.6)':
dependencies:
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
'@csstools/media-query-list-parser': 2.1.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-nested-calc@3.0.2(postcss@8.4.39)':
+ '@csstools/postcss-nested-calc@3.0.2(postcss@8.5.6)':
dependencies:
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-normalize-display-values@3.0.2(postcss@8.4.39)':
+ '@csstools/postcss-normalize-display-values@3.0.2(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-oklab-function@3.0.17(postcss@8.4.39)':
+ '@csstools/postcss-oklab-function@3.0.17(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-progressive-custom-properties@3.2.0(postcss@8.4.39)':
+ '@csstools/postcss-progressive-custom-properties@3.2.0(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-relative-color-syntax@2.0.17(postcss@8.4.39)':
+ '@csstools/postcss-relative-color-syntax@2.0.17(postcss@8.5.6)':
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- '@csstools/postcss-scope-pseudo-class@3.0.1(postcss@8.4.39)':
+ '@csstools/postcss-scope-pseudo-class@3.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- '@csstools/postcss-stepped-value-functions@3.0.9(postcss@8.4.39)':
+ '@csstools/postcss-stepped-value-functions@3.0.9(postcss@8.5.6)':
dependencies:
'@csstools/css-calc': 1.2.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-text-decoration-shorthand@3.0.7(postcss@8.4.39)':
+ '@csstools/postcss-text-decoration-shorthand@3.0.7(postcss@8.5.6)':
dependencies:
'@csstools/color-helpers': 4.2.1
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- '@csstools/postcss-trigonometric-functions@3.0.9(postcss@8.4.39)':
+ '@csstools/postcss-trigonometric-functions@3.0.9(postcss@8.5.6)':
dependencies:
'@csstools/css-calc': 1.2.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- postcss: 8.4.39
+ postcss: 8.5.6
- '@csstools/postcss-unset-value@3.0.1(postcss@8.4.39)':
+ '@csstools/postcss-unset-value@3.0.1(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
'@csstools/selector-resolve-nested@1.1.0(postcss-selector-parser@6.1.2)':
dependencies:
@@ -10753,9 +10800,9 @@ snapshots:
dependencies:
postcss-selector-parser: 6.1.2
- '@csstools/utilities@1.0.0(postcss@8.4.39)':
+ '@csstools/utilities@1.0.0(postcss@8.5.6)':
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
'@discoveryjs/json-ext@0.5.7': {}
@@ -11258,7 +11305,7 @@ snapshots:
fast-deep-equal: 3.1.3
functional-red-black-tree: 1.0.1
google-gax: 2.30.5
- protobufjs: 6.11.4
+ protobufjs: 6.11.6
transitivePeerDependencies:
- encoding
- supports-color
@@ -11309,20 +11356,20 @@ snapshots:
'@grpc/grpc-js@1.6.12':
dependencies:
'@grpc/proto-loader': 0.7.10
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
optional: true
'@grpc/grpc-js@1.9.15':
dependencies:
'@grpc/proto-loader': 0.7.10
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
'@grpc/proto-loader@0.6.13':
dependencies:
'@types/long': 4.0.2
lodash.camelcase: 4.3.0
long: 4.0.0
- protobufjs: 6.11.4
+ protobufjs: 6.11.6
yargs: 16.2.0
optional: true
@@ -11330,7 +11377,7 @@ snapshots:
dependencies:
lodash.camelcase: 4.3.0
long: 5.2.3
- protobufjs: 7.2.5
+ protobufjs: 7.5.8
yargs: 17.7.2
'@humanwhocodes/config-array@0.13.0':
@@ -11367,7 +11414,7 @@ snapshots:
'@jest/console@30.2.0':
dependencies:
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
chalk: 4.1.2
jest-message-util: 30.2.0
jest-util: 30.2.0
@@ -11381,14 +11428,14 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 4.3.0
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.2.0
- jest-config: 30.2.0(@types/node@24.6.2)
+ jest-config: 30.2.0(@types/node@25.1.0)
jest-haste-map: 30.2.0
jest-message-util: 30.2.0
jest-regex-util: 30.0.1
@@ -11411,24 +11458,31 @@ snapshots:
'@jest/diff-sequences@30.0.1': {}
- '@jest/environment-jsdom-abstract@30.2.0(jsdom@26.1.0)':
+ '@jest/environment-jsdom-abstract@30.3.0(jsdom@26.1.0)':
dependencies:
- '@jest/environment': 30.2.0
- '@jest/fake-timers': 30.2.0
- '@jest/types': 30.2.0
+ '@jest/environment': 30.3.0
+ '@jest/fake-timers': 30.3.0
+ '@jest/types': 30.3.0
'@types/jsdom': 21.1.7
- '@types/node': 24.6.2
- jest-mock: 30.2.0
- jest-util: 30.2.0
+ '@types/node': 25.6.0
+ jest-mock: 30.3.0
+ jest-util: 30.3.0
jsdom: 26.1.0
'@jest/environment@30.2.0':
dependencies:
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
jest-mock: 30.2.0
+ '@jest/environment@30.3.0':
+ dependencies:
+ '@jest/fake-timers': 30.3.0
+ '@jest/types': 30.3.0
+ '@types/node': 25.6.0
+ jest-mock: 30.3.0
+
'@jest/expect-utils@30.2.0':
dependencies:
'@jest/get-type': 30.1.0
@@ -11444,11 +11498,20 @@ snapshots:
dependencies:
'@jest/types': 30.2.0
'@sinonjs/fake-timers': 13.0.5
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
jest-message-util: 30.2.0
jest-mock: 30.2.0
jest-util: 30.2.0
+ '@jest/fake-timers@30.3.0':
+ dependencies:
+ '@jest/types': 30.3.0
+ '@sinonjs/fake-timers': 15.3.2
+ '@types/node': 25.6.0
+ jest-message-util: 30.3.0
+ jest-mock: 30.3.0
+ jest-util: 30.3.0
+
'@jest/get-type@30.1.0': {}
'@jest/globals@30.2.0':
@@ -11462,7 +11525,7 @@ snapshots:
'@jest/pattern@30.0.1':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
jest-regex-util: 30.0.1
'@jest/reporters@30.2.0':
@@ -11473,7 +11536,7 @@ snapshots:
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
'@jridgewell/trace-mapping': 0.3.31
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
chalk: 4.1.2
collect-v8-coverage: 1.0.2
exit-x: 0.2.2
@@ -11553,8 +11616,8 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 24.6.2
- '@types/yargs': 17.0.33
+ '@types/node': 25.6.0
+ '@types/yargs': 17.0.35
chalk: 4.1.2
'@jest/types@30.2.0':
@@ -11563,10 +11626,20 @@ snapshots:
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
'@types/yargs': 17.0.33
chalk: 4.1.2
+ '@jest/types@30.3.0':
+ dependencies:
+ '@jest/pattern': 30.0.1
+ '@jest/schemas': 30.0.5
+ '@types/istanbul-lib-coverage': 2.0.6
+ '@types/istanbul-reports': 3.0.4
+ '@types/node': 25.6.0
+ '@types/yargs': 17.0.35
+ chalk: 4.1.2
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -11653,7 +11726,7 @@ snapshots:
'@npmcli/fs@1.1.1':
dependencies:
'@gar/promisify': 1.1.3
- semver: 7.7.2
+ semver: 7.7.3
'@npmcli/move-file@1.1.2':
dependencies:
@@ -11676,19 +11749,19 @@ snapshots:
'@babel/preset-env': 7.24.7(@babel/core@7.24.7)
'@babel/runtime': 7.24.7
'@vue/babel-preset-jsx': 1.4.0(@babel/core@7.24.7)(vue@2.7.16)
- core-js: 3.45.1
+ core-js: 3.49.0
core-js-compat: 3.37.1
regenerator-runtime: 0.14.1
transitivePeerDependencies:
- supports-color
- vue
- '@nuxt/builder@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)':
+ '@nuxt/builder@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)':
dependencies:
'@nuxt/devalue': 2.0.2
'@nuxt/utils': 2.18.1
'@nuxt/vue-app': 2.18.1
- '@nuxt/webpack': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)
+ '@nuxt/webpack': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)
chalk: 4.1.2
chokidar: 3.6.0
consola: 3.2.3
@@ -11785,7 +11858,7 @@ snapshots:
minimist: 1.2.8
opener: 1.5.2
pretty-bytes: 5.6.0
- semver: 7.7.2
+ semver: 7.7.3
serve-static: 1.16.2
std-env: 3.7.0
upath: 2.0.1
@@ -11802,7 +11875,7 @@ snapshots:
glob: 7.2.3
globby: 11.1.0
scule: 0.2.1
- semver: 7.7.2
+ semver: 7.7.3
upath: 2.0.1
vue-template-compiler: 2.7.16
@@ -11816,7 +11889,7 @@ snapshots:
lodash: 4.17.21
rc9: 2.1.2
std-env: 3.7.0
- ufo: 1.6.1
+ ufo: 1.6.4
'@nuxt/core@2.18.1':
dependencies:
@@ -11851,11 +11924,11 @@ snapshots:
fs-extra: 11.2.0
html-minifier-terser: 7.2.0
node-html-parser: 6.1.13
- ufo: 1.6.1
+ ufo: 1.6.4
- '@nuxt/kit@3.12.2(rollup@3.29.5)':
+ '@nuxt/kit@3.12.2(rollup@3.30.0)':
dependencies:
- '@nuxt/schema': 3.12.2(rollup@3.29.5)
+ '@nuxt/schema': 3.12.2(rollup@3.30.0)
c12: 1.11.1
consola: 3.2.3
defu: 6.1.4
@@ -11870,19 +11943,19 @@ snapshots:
pathe: 1.1.2
pkg-types: 1.1.2
scule: 1.3.0
- semver: 7.7.2
- ufo: 1.6.1
+ semver: 7.7.3
+ ufo: 1.6.4
unctx: 2.3.1
- unimport: 3.7.2(rollup@3.29.5)
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- magicast
- rollup
- supports-color
- '@nuxt/kit@3.7.4(rollup@3.29.5)':
+ '@nuxt/kit@3.7.4(rollup@3.30.0)':
dependencies:
- '@nuxt/schema': 3.7.4(rollup@3.29.5)
+ '@nuxt/schema': 3.7.4(rollup@3.30.0)
c12: 1.4.2
consola: 3.2.3
defu: 6.1.4
@@ -11895,10 +11968,10 @@ snapshots:
pathe: 1.1.2
pkg-types: 1.1.2
scule: 1.0.0
- semver: 7.7.2
- ufo: 1.6.1
+ semver: 7.7.3
+ ufo: 1.6.4
unctx: 2.3.1
- unimport: 3.4.0(rollup@3.29.5)
+ unimport: 3.4.0(rollup@3.30.0)
untyped: 1.4.0
transitivePeerDependencies:
- rollup
@@ -11920,7 +11993,7 @@ snapshots:
consola: 3.2.3
node-fetch-native: 1.6.7
- '@nuxt/schema@3.12.2(rollup@3.29.5)':
+ '@nuxt/schema@3.12.2(rollup@3.30.0)':
dependencies:
compatx: 0.1.8
consola: 3.2.3
@@ -11930,15 +12003,15 @@ snapshots:
pkg-types: 1.1.2
scule: 1.3.0
std-env: 3.7.0
- ufo: 1.6.1
+ ufo: 1.6.4
uncrypto: 0.1.3
- unimport: 3.7.2(rollup@3.29.5)
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- rollup
- supports-color
- '@nuxt/schema@3.7.4(rollup@3.29.5)':
+ '@nuxt/schema@3.7.4(rollup@3.30.0)':
dependencies:
'@nuxt/ui-templates': 1.3.1
consola: 3.2.3
@@ -11948,8 +12021,8 @@ snapshots:
pkg-types: 1.1.2
postcss-import-resolver: 2.0.0
std-env: 3.7.0
- ufo: 1.6.1
- unimport: 3.7.2(rollup@3.29.5)
+ ufo: 1.6.4
+ unimport: 3.7.2(rollup@3.30.0)
untyped: 1.4.2
transitivePeerDependencies:
- rollup
@@ -11973,7 +12046,7 @@ snapshots:
serve-placeholder: 2.0.2
serve-static: 1.16.2
server-destroy: 1.0.1
- ufo: 1.6.1
+ ufo: 1.6.4
transitivePeerDependencies:
- supports-color
@@ -12017,13 +12090,13 @@ snapshots:
'@types/webpack-bundle-analyzer': 3.9.5
'@types/webpack-hot-middleware': 2.25.5
- '@nuxt/typescript-build@3.0.2(@nuxt/types@2.18.1)(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.102.0)':
+ '@nuxt/typescript-build@3.0.2(@nuxt/types@2.18.1)(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.104.1)':
dependencies:
'@nuxt/types': 2.18.1
consola: 3.2.3
defu: 6.1.2
- fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.102.0)
- ts-loader: 8.4.0(typescript@4.9.5)(webpack@5.102.0)
+ fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.104.1)
+ ts-loader: 8.4.0(typescript@4.9.5)(webpack@5.104.1)
typescript: 4.9.5
transitivePeerDependencies:
- eslint
@@ -12041,16 +12114,16 @@ snapshots:
jiti: 1.21.6
lodash: 4.17.21
proper-lockfile: 4.1.2
- semver: 7.7.2
+ semver: 7.7.3
serialize-javascript: 6.0.2
signal-exit: 4.1.0
ua-parser-js: 1.0.38
- ufo: 1.6.1
+ ufo: 1.6.4
'@nuxt/vue-app@2.18.1':
dependencies:
node-fetch-native: 1.6.7
- ufo: 1.6.1
+ ufo: 1.6.4
unfetch: 5.0.0
vue: 2.7.16
vue-client-only: 2.1.0
@@ -12069,12 +12142,12 @@ snapshots:
fs-extra: 11.2.0
lodash: 4.17.21
lru-cache: 5.1.1
- ufo: 1.6.1
+ ufo: 1.6.4
vue: 2.7.16
vue-meta: 2.4.0
vue-server-renderer: 2.7.16
- '@nuxt/webpack@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)':
+ '@nuxt/webpack@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)':
dependencies:
'@babel/core': 7.24.7
'@nuxt/babel-preset-app': 2.18.1(vue@2.7.16)
@@ -12085,7 +12158,7 @@ snapshots:
caniuse-lite: 1.0.30001639
consola: 3.2.3
css-loader: 5.2.7(webpack@4.47.0)
- cssnano: 7.0.3(postcss@8.4.39)
+ cssnano: 7.0.3(postcss@8.5.6)
eventsource-polyfill: 0.9.6
extract-css-chunks-webpack-plugin: 4.10.0(webpack@4.47.0)
file-loader: 6.2.0(webpack@4.47.0)
@@ -12099,25 +12172,25 @@ snapshots:
optimize-css-assets-webpack-plugin: 6.0.1(webpack@4.47.0)
pify: 5.0.0
pnp-webpack-plugin: 1.7.0(typescript@4.9.5)
- postcss: 8.4.39
- postcss-import: 15.1.0(postcss@8.4.39)
+ postcss: 8.5.6
+ postcss-import: 15.1.0(postcss@8.5.6)
postcss-import-resolver: 2.0.0
- postcss-loader: 4.3.0(postcss@8.4.39)(webpack@4.47.0)
- postcss-preset-env: 9.5.15(postcss@8.4.39)
- postcss-url: 10.1.3(postcss@8.4.39)
- semver: 7.7.2
+ postcss-loader: 4.3.0(postcss@8.5.6)(webpack@4.47.0)
+ postcss-preset-env: 9.5.15(postcss@8.5.6)
+ postcss-url: 10.1.3(postcss@8.5.6)
+ semver: 7.7.3
std-env: 3.7.0
style-resources-loader: 1.5.0(webpack@4.47.0)
terser-webpack-plugin: 4.2.3(webpack@4.47.0)
thread-loader: 3.0.4(webpack@4.47.0)
time-fix-plugin: 2.0.7(webpack@4.47.0)
- ufo: 1.6.1
+ ufo: 1.6.4
upath: 2.0.1
- url-loader: 4.1.1(file-loader@6.2.0(webpack@5.102.0))(webpack@4.47.0)
- vue-loader: 15.11.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(cache-loader@4.1.0(webpack@4.47.0))(css-loader@5.2.7(webpack@5.102.0))(ejs@3.1.10)(handlebars@4.7.8)(lodash@4.17.21)(prettier@3.6.2)(vue-template-compiler@2.7.16)(webpack@4.47.0)
+ url-loader: 4.1.1(file-loader@6.2.0(webpack@5.104.1))(webpack@4.47.0)
+ vue-loader: 15.11.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(cache-loader@4.1.0(webpack@4.47.0))(css-loader@5.2.7(webpack@5.104.1))(ejs@3.1.10)(handlebars@4.7.8)(lodash@4.17.21)(prettier@3.8.1)(vue-template-compiler@2.7.16)(webpack@4.47.0)
vue-style-loader: 4.1.3
vue-template-compiler: 2.7.16
- watchpack: 2.4.4
+ watchpack: 2.5.0
webpack: 4.47.0
webpack-bundle-analyzer: 4.10.2
webpack-dev-middleware: 5.3.4(webpack@4.47.0)
@@ -12225,25 +12298,25 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.29.5)(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))(webpack@5.102.0)':
+ '@nuxtjs/eslint-module@4.1.0(eslint@8.57.1)(rollup@3.30.0)(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
dependencies:
- '@nuxt/kit': 3.7.4(rollup@3.29.5)
+ '@nuxt/kit': 3.7.4(rollup@3.30.0)
chokidar: 3.5.3
eslint: 8.57.1
- eslint-webpack-plugin: 4.0.1(eslint@8.57.1)(webpack@5.102.0)
+ eslint-webpack-plugin: 4.0.1(eslint@8.57.1)(webpack@5.104.1)
pathe: 1.1.1
- vite-plugin-eslint: 1.8.1(eslint@8.57.1)(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))
+ vite-plugin-eslint: 1.8.1(eslint@8.57.1)(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))
transitivePeerDependencies:
- rollup
- supports-color
- vite
- webpack
- '@nuxtjs/firebase@8.2.2(@firebase/app-types@0.9.2)(firebase@10.14.1)(nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16))':
+ '@nuxtjs/firebase@8.2.2(@firebase/app-types@0.9.2)(firebase@10.14.1)(nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16))':
dependencies:
consola: 2.15.3
firebase: 10.14.1
- nuxt: 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)
+ nuxt: 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)
optionalDependencies:
firebase-admin: 10.3.0(@firebase/app-types@0.9.2)
transitivePeerDependencies:
@@ -12263,14 +12336,14 @@ snapshots:
minimatch: 3.1.2
sitemap: 4.1.1
- '@nuxtjs/stylelint-module@5.2.0(postcss@8.4.39)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))(webpack@5.102.0)':
+ '@nuxtjs/stylelint-module@5.2.0(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))(webpack@5.104.1)':
dependencies:
- '@nuxt/kit': 3.12.2(rollup@3.29.5)
+ '@nuxt/kit': 3.12.2(rollup@3.30.0)
chokidar: 3.6.0
pathe: 1.1.2
stylelint: 15.11.0(typescript@4.9.5)
- stylelint-webpack-plugin: 5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.102.0)
- vite-plugin-stylelint: 5.3.1(postcss@8.4.39)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0))
+ stylelint-webpack-plugin: 5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.104.1)
+ vite-plugin-stylelint: 5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1))
transitivePeerDependencies:
- '@types/stylelint'
- magicast
@@ -12280,13 +12353,13 @@ snapshots:
- vite
- webpack
- '@nuxtjs/vuetify@1.12.3(vue@2.7.16)(webpack@5.102.0)':
+ '@nuxtjs/vuetify@1.12.3(vue@2.7.16)(webpack@5.104.1)':
dependencies:
deepmerge: 4.3.1
sass: 1.32.13
- sass-loader: 10.4.1(sass@1.32.13)(webpack@5.102.0)
+ sass-loader: 10.4.1(sass@1.32.13)(webpack@5.104.1)
vuetify: 2.7.2(vue@2.7.16)
- vuetify-loader: 1.9.2(vue@2.7.16)(vuetify@2.7.2(vue@2.7.16))(webpack@5.102.0)
+ vuetify-loader: 1.9.2(vue@2.7.16)(vuetify@2.7.2(vue@2.7.16))(webpack@5.104.1)
transitivePeerDependencies:
- fibers
- gm
@@ -12318,7 +12391,10 @@ snapshots:
'@protobufjs/base64@1.1.2': {}
- '@protobufjs/codegen@2.0.4': {}
+ '@protobufjs/codegen@2.0.4':
+ optional: true
+
+ '@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.0': {}
@@ -12331,32 +12407,39 @@ snapshots:
'@protobufjs/inquire@1.1.0': {}
+ '@protobufjs/inquire@1.1.1': {}
+
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
- '@protobufjs/utf8@1.1.0': {}
+ '@protobufjs/utf8@1.1.0':
+ optional: true
+
+ '@protobufjs/utf8@1.1.1': {}
'@rollup/pluginutils@4.2.1':
dependencies:
estree-walker: 2.0.2
picomatch: 2.3.1
- '@rollup/pluginutils@5.0.4(rollup@3.29.5)':
+ '@rollup/pluginutils@5.0.4(rollup@3.30.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
- picomatch: 2.3.1
+ picomatch: 2.3.2
optionalDependencies:
- rollup: 3.29.5
+ rollup: 3.30.0
- '@rollup/pluginutils@5.1.0(rollup@3.29.5)':
+ '@rollup/pluginutils@5.1.0(rollup@3.30.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 2.3.1
optionalDependencies:
- rollup: 3.29.5
+ rollup: 3.30.0
+
+ '@simple-libs/stream-utils@1.2.0': {}
'@sinclair/typebox@0.27.8': {}
@@ -12372,6 +12455,10 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@sinonjs/fake-timers@15.3.2':
+ dependencies:
+ '@sinonjs/commons': 3.0.1
+
'@tootallnate/once@2.0.0':
optional: true
@@ -12406,7 +12493,7 @@ snapshots:
'@types/body-parser@1.19.3':
dependencies:
'@types/connect': 3.4.38
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
'@types/compression@1.7.5':
dependencies:
@@ -12416,10 +12503,6 @@ snapshots:
dependencies:
'@types/node': 16.18.55
- '@types/conventional-commits-parser@5.0.1':
- dependencies:
- '@types/node': 24.6.2
-
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -12443,7 +12526,7 @@ snapshots:
'@types/express-serve-static-core@4.17.37':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
'@types/qs': 6.9.8
'@types/range-parser': 1.2.5
'@types/send': 0.17.2
@@ -12477,7 +12560,7 @@ snapshots:
'@types/jsdom@21.1.7':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
'@types/tough-cookie': 4.0.5
parse5: 7.3.0
@@ -12487,7 +12570,7 @@ snapshots:
'@types/jsonwebtoken@8.5.9':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
optional: true
'@types/less@3.0.6': {}
@@ -12503,11 +12586,13 @@ snapshots:
'@types/node@16.18.55': {}
- '@types/node@20.8.0': {}
+ '@types/node@25.1.0':
+ dependencies:
+ undici-types: 7.16.0
- '@types/node@24.6.2':
+ '@types/node@25.6.0':
dependencies:
- undici-types: 7.13.0
+ undici-types: 7.19.2
'@types/normalize-package-data@2.4.2': {}
@@ -12519,9 +12604,9 @@ snapshots:
'@types/pug@2.0.10': {}
- '@types/qrcode@1.5.5':
+ '@types/qrcode@1.5.6':
dependencies:
- '@types/node': 20.8.0
+ '@types/node': 25.1.0
'@types/qs@6.9.8': {}
@@ -12529,14 +12614,14 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 12.20.55
'@types/semver@7.5.3': {}
'@types/send@0.17.2':
dependencies:
'@types/mime': 1.3.3
- '@types/node': 24.6.2
+ '@types/node': 16.18.55
'@types/serve-static@1.15.7':
dependencies:
@@ -12576,7 +12661,7 @@ snapshots:
'@types/webpack-sources@3.2.1':
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 16.18.55
'@types/source-list-map': 0.1.3
source-map: 0.7.6
@@ -12595,6 +12680,10 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
+ '@types/yargs@17.0.35':
+ dependencies:
+ '@types/yargs-parser': 21.0.3
+
'@typescript-eslint/eslint-plugin@6.7.3(@typescript-eslint/parser@6.7.3(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
dependencies:
'@eslint-community/regexpp': 4.9.0
@@ -12608,7 +12697,7 @@ snapshots:
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
- semver: 7.7.2
+ semver: 7.7.3
ts-api-utils: 1.0.3(typescript@4.9.5)
optionalDependencies:
typescript: 4.9.5
@@ -12654,7 +12743,7 @@ snapshots:
debug: 4.4.1
globby: 11.1.0
is-glob: 4.0.3
- semver: 7.7.2
+ semver: 7.7.3
ts-api-utils: 1.0.3(typescript@4.9.5)
optionalDependencies:
typescript: 4.9.5
@@ -12670,7 +12759,7 @@ snapshots:
'@typescript-eslint/types': 6.7.3
'@typescript-eslint/typescript-estree': 6.7.3(typescript@4.9.5)
eslint: 8.57.1
- semver: 7.7.2
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
- typescript
@@ -12817,7 +12906,7 @@ snapshots:
'@vue/compiler-sfc@2.7.16':
dependencies:
'@babel/parser': 7.24.0
- postcss: 8.4.35
+ postcss: 8.5.6
source-map: 0.6.1
optionalDependencies:
prettier: 2.8.8
@@ -13068,11 +13157,6 @@ snapshots:
'@xtuc/long@4.2.2': {}
- JSONStream@1.3.5:
- dependencies:
- jsonparse: 1.3.1
- through: 2.3.8
-
abbrev@1.1.1: {}
abort-controller@3.0.0:
@@ -13316,22 +13400,22 @@ snapshots:
atob@2.1.2: {}
- autoprefixer@10.4.19(postcss@8.4.39):
+ autoprefixer@10.4.19(postcss@8.5.6):
dependencies:
- browserslist: 4.26.2
- caniuse-lite: 1.0.30001746
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001762
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.5: {}
- axios@0.30.2:
+ axios@0.32.0:
dependencies:
- follow-redirects: 1.15.11
- form-data: 4.0.4
+ follow-redirects: 1.16.0
+ form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -13503,9 +13587,7 @@ snapshots:
mixin-deep: 1.3.2
pascalcase: 0.1.1
- baseline-browser-mapping@2.8.10: {}
-
- baseline-browser-mapping@2.8.9: {}
+ baseline-browser-mapping@2.9.11: {}
big.js@5.2.2: {}
@@ -13553,6 +13635,11 @@ snapshots:
dependencies:
balanced-match: 1.0.2
+ brace-expansion@2.1.1:
+ dependencies:
+ balanced-match: 1.0.2
+ optional: true
+
braces@2.3.2:
dependencies:
arr-flatten: 1.1.0
@@ -13621,21 +13708,13 @@ snapshots:
dependencies:
pako: 1.0.11
- browserslist@4.26.2:
+ browserslist@4.28.1:
dependencies:
- baseline-browser-mapping: 2.8.9
- caniuse-lite: 1.0.30001746
- electron-to-chromium: 1.5.227
- node-releases: 2.0.21
- update-browserslist-db: 1.1.3(browserslist@4.26.2)
-
- browserslist@4.26.3:
- dependencies:
- baseline-browser-mapping: 2.8.10
- caniuse-lite: 1.0.30001746
- electron-to-chromium: 1.5.228
- node-releases: 2.0.21
- update-browserslist-db: 1.1.3(browserslist@4.26.3)
+ baseline-browser-mapping: 2.9.11
+ caniuse-lite: 1.0.30001762
+ electron-to-chromium: 1.5.267
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
bs-logger@0.2.6:
dependencies:
@@ -13666,7 +13745,7 @@ snapshots:
builtins@5.0.1:
dependencies:
- semver: 7.7.2
+ semver: 7.7.3
bytes@3.0.0: {}
@@ -13796,14 +13875,14 @@ snapshots:
caniuse-api@3.0.0:
dependencies:
- browserslist: 4.26.3
- caniuse-lite: 1.0.30001746
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001762
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
caniuse-lite@1.0.30001639: {}
- caniuse-lite@1.0.30001746: {}
+ caniuse-lite@1.0.30001762: {}
chalk@1.1.3:
dependencies:
@@ -13826,19 +13905,17 @@ snapshots:
chalk@5.5.0: {}
- chalk@5.6.2: {}
-
char-regex@1.0.2: {}
chardet@0.7.0: {}
- chart.js@4.5.0:
+ chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
- chartjs-adapter-moment@1.0.1(chart.js@4.5.0)(moment@2.30.1):
+ chartjs-adapter-moment@1.0.1(chart.js@4.5.1)(moment@2.30.1):
dependencies:
- chart.js: 4.5.0
+ chart.js: 4.5.1
moment: 2.30.1
chokidar@2.1.8:
@@ -13896,6 +13973,8 @@ snapshots:
ci-info@4.3.0: {}
+ ci-info@4.4.0: {}
+
cipher-base@1.0.4:
dependencies:
inherits: 2.0.4
@@ -14092,20 +14171,22 @@ snapshots:
constants-browserify@1.0.0: {}
- conventional-changelog-angular@7.0.0:
+ conventional-changelog-angular@8.1.0:
dependencies:
compare-func: 2.0.0
- conventional-changelog-conventionalcommits@7.0.2:
+ conventional-changelog-conventionalcommits@9.3.1:
dependencies:
compare-func: 2.0.0
- conventional-commits-parser@5.0.0:
+ conventional-commits-parser@6.2.1:
dependencies:
- JSONStream: 1.3.5
- is-text-path: 2.0.0
- meow: 12.1.1
- split2: 4.2.0
+ meow: 13.2.0
+
+ conventional-commits-parser@6.4.0:
+ dependencies:
+ '@simple-libs/stream-utils': 1.2.0
+ meow: 13.2.0
convert-source-map@2.0.0: {}
@@ -14124,17 +14205,17 @@ snapshots:
core-js-compat@3.37.1:
dependencies:
- browserslist: 4.26.2
+ browserslist: 4.28.1
core-js@2.6.12: {}
- core-js@3.45.1: {}
+ core-js@3.49.0: {}
core-util-is@1.0.3: {}
- cosmiconfig-typescript-loader@6.1.0(@types/node@24.6.2)(cosmiconfig@9.0.0(typescript@4.9.5))(typescript@4.9.5):
+ cosmiconfig-typescript-loader@6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.0(typescript@4.9.5))(typescript@4.9.5):
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
cosmiconfig: 9.0.0(typescript@4.9.5)
jiti: 2.6.1
typescript: 4.9.5
@@ -14168,7 +14249,7 @@ snapshots:
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
- js-yaml: 4.1.0
+ js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
typescript: 4.9.5
@@ -14222,59 +14303,59 @@ snapshots:
crypto-random-string@2.0.0:
optional: true
- css-blank-pseudo@6.0.2(postcss@8.4.39):
+ css-blank-pseudo@6.0.2(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- css-declaration-sorter@6.4.1(postcss@8.4.39):
+ css-declaration-sorter@6.4.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- css-declaration-sorter@7.2.0(postcss@8.4.39):
+ css-declaration-sorter@7.2.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
css-functions-list@3.2.1: {}
- css-has-pseudo@6.0.5(postcss@8.4.39):
+ css-has-pseudo@6.0.5(postcss@8.5.6):
dependencies:
'@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2)
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-value-parser: 4.2.0
css-loader@5.2.7(webpack@4.47.0):
dependencies:
- icss-utils: 5.1.0(postcss@8.4.39)
+ icss-utils: 5.1.0(postcss@8.5.6)
loader-utils: 2.0.4
- postcss: 8.4.39
- postcss-modules-extract-imports: 3.0.0(postcss@8.4.39)
- postcss-modules-local-by-default: 4.0.3(postcss@8.4.39)
- postcss-modules-scope: 3.0.0(postcss@8.4.39)
- postcss-modules-values: 4.0.0(postcss@8.4.39)
+ postcss: 8.5.6
+ postcss-modules-extract-imports: 3.0.0(postcss@8.5.6)
+ postcss-modules-local-by-default: 4.0.3(postcss@8.5.6)
+ postcss-modules-scope: 3.0.0(postcss@8.5.6)
+ postcss-modules-values: 4.0.0(postcss@8.5.6)
postcss-value-parser: 4.2.0
schema-utils: 3.3.0
- semver: 7.7.2
+ semver: 7.7.3
webpack: 4.47.0
- css-loader@5.2.7(webpack@5.102.0):
+ css-loader@5.2.7(webpack@5.104.1):
dependencies:
- icss-utils: 5.1.0(postcss@8.4.39)
+ icss-utils: 5.1.0(postcss@8.5.6)
loader-utils: 2.0.4
- postcss: 8.4.39
- postcss-modules-extract-imports: 3.0.0(postcss@8.4.39)
- postcss-modules-local-by-default: 4.0.3(postcss@8.4.39)
- postcss-modules-scope: 3.0.0(postcss@8.4.39)
- postcss-modules-values: 4.0.0(postcss@8.4.39)
+ postcss: 8.5.6
+ postcss-modules-extract-imports: 3.0.0(postcss@8.5.6)
+ postcss-modules-local-by-default: 4.0.3(postcss@8.5.6)
+ postcss-modules-scope: 3.0.0(postcss@8.5.6)
+ postcss-modules-values: 4.0.0(postcss@8.5.6)
postcss-value-parser: 4.2.0
schema-utils: 3.3.0
- semver: 7.7.2
- webpack: 5.102.0
+ semver: 7.7.3
+ webpack: 5.104.1
- css-prefers-color-scheme@9.0.1(postcss@8.4.39):
+ css-prefers-color-scheme@9.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
css-select@4.3.0:
dependencies:
@@ -14289,7 +14370,7 @@ snapshots:
boolbase: 1.0.0
css-what: 6.1.0
domhandler: 5.0.3
- domutils: 3.1.0
+ domutils: 3.2.2
nth-check: 2.1.1
css-tree@1.1.3:
@@ -14300,7 +14381,7 @@ snapshots:
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
- source-map-js: 1.0.2
+ source-map-js: 1.2.1
css-tree@2.3.1:
dependencies:
@@ -14320,93 +14401,93 @@ snapshots:
cssesc@3.0.0: {}
- cssnano-preset-default@5.2.14(postcss@8.4.39):
- dependencies:
- css-declaration-sorter: 6.4.1(postcss@8.4.39)
- cssnano-utils: 3.1.0(postcss@8.4.39)
- postcss: 8.4.39
- postcss-calc: 8.2.4(postcss@8.4.39)
- postcss-colormin: 5.3.1(postcss@8.4.39)
- postcss-convert-values: 5.1.3(postcss@8.4.39)
- postcss-discard-comments: 5.1.2(postcss@8.4.39)
- postcss-discard-duplicates: 5.1.0(postcss@8.4.39)
- postcss-discard-empty: 5.1.1(postcss@8.4.39)
- postcss-discard-overridden: 5.1.0(postcss@8.4.39)
- postcss-merge-longhand: 5.1.7(postcss@8.4.39)
- postcss-merge-rules: 5.1.4(postcss@8.4.39)
- postcss-minify-font-values: 5.1.0(postcss@8.4.39)
- postcss-minify-gradients: 5.1.1(postcss@8.4.39)
- postcss-minify-params: 5.1.4(postcss@8.4.39)
- postcss-minify-selectors: 5.2.1(postcss@8.4.39)
- postcss-normalize-charset: 5.1.0(postcss@8.4.39)
- postcss-normalize-display-values: 5.1.0(postcss@8.4.39)
- postcss-normalize-positions: 5.1.1(postcss@8.4.39)
- postcss-normalize-repeat-style: 5.1.1(postcss@8.4.39)
- postcss-normalize-string: 5.1.0(postcss@8.4.39)
- postcss-normalize-timing-functions: 5.1.0(postcss@8.4.39)
- postcss-normalize-unicode: 5.1.1(postcss@8.4.39)
- postcss-normalize-url: 5.1.0(postcss@8.4.39)
- postcss-normalize-whitespace: 5.1.1(postcss@8.4.39)
- postcss-ordered-values: 5.1.3(postcss@8.4.39)
- postcss-reduce-initial: 5.1.2(postcss@8.4.39)
- postcss-reduce-transforms: 5.1.0(postcss@8.4.39)
- postcss-svgo: 5.1.0(postcss@8.4.39)
- postcss-unique-selectors: 5.1.1(postcss@8.4.39)
-
- cssnano-preset-default@7.0.3(postcss@8.4.39):
- dependencies:
- browserslist: 4.26.2
- css-declaration-sorter: 7.2.0(postcss@8.4.39)
- cssnano-utils: 5.0.0(postcss@8.4.39)
- postcss: 8.4.39
- postcss-calc: 10.0.0(postcss@8.4.39)
- postcss-colormin: 7.0.1(postcss@8.4.39)
- postcss-convert-values: 7.0.1(postcss@8.4.39)
- postcss-discard-comments: 7.0.1(postcss@8.4.39)
- postcss-discard-duplicates: 7.0.0(postcss@8.4.39)
- postcss-discard-empty: 7.0.0(postcss@8.4.39)
- postcss-discard-overridden: 7.0.0(postcss@8.4.39)
- postcss-merge-longhand: 7.0.2(postcss@8.4.39)
- postcss-merge-rules: 7.0.2(postcss@8.4.39)
- postcss-minify-font-values: 7.0.0(postcss@8.4.39)
- postcss-minify-gradients: 7.0.0(postcss@8.4.39)
- postcss-minify-params: 7.0.1(postcss@8.4.39)
- postcss-minify-selectors: 7.0.2(postcss@8.4.39)
- postcss-normalize-charset: 7.0.0(postcss@8.4.39)
- postcss-normalize-display-values: 7.0.0(postcss@8.4.39)
- postcss-normalize-positions: 7.0.0(postcss@8.4.39)
- postcss-normalize-repeat-style: 7.0.0(postcss@8.4.39)
- postcss-normalize-string: 7.0.0(postcss@8.4.39)
- postcss-normalize-timing-functions: 7.0.0(postcss@8.4.39)
- postcss-normalize-unicode: 7.0.1(postcss@8.4.39)
- postcss-normalize-url: 7.0.0(postcss@8.4.39)
- postcss-normalize-whitespace: 7.0.0(postcss@8.4.39)
- postcss-ordered-values: 7.0.1(postcss@8.4.39)
- postcss-reduce-initial: 7.0.1(postcss@8.4.39)
- postcss-reduce-transforms: 7.0.0(postcss@8.4.39)
- postcss-svgo: 7.0.1(postcss@8.4.39)
- postcss-unique-selectors: 7.0.1(postcss@8.4.39)
-
- cssnano-utils@3.1.0(postcss@8.4.39):
- dependencies:
- postcss: 8.4.39
-
- cssnano-utils@5.0.0(postcss@8.4.39):
- dependencies:
- postcss: 8.4.39
-
- cssnano@5.1.15(postcss@8.4.39):
- dependencies:
- cssnano-preset-default: 5.2.14(postcss@8.4.39)
+ cssnano-preset-default@5.2.14(postcss@8.5.6):
+ dependencies:
+ css-declaration-sorter: 6.4.1(postcss@8.5.6)
+ cssnano-utils: 3.1.0(postcss@8.5.6)
+ postcss: 8.5.6
+ postcss-calc: 8.2.4(postcss@8.5.6)
+ postcss-colormin: 5.3.1(postcss@8.5.6)
+ postcss-convert-values: 5.1.3(postcss@8.5.6)
+ postcss-discard-comments: 5.1.2(postcss@8.5.6)
+ postcss-discard-duplicates: 5.1.0(postcss@8.5.6)
+ postcss-discard-empty: 5.1.1(postcss@8.5.6)
+ postcss-discard-overridden: 5.1.0(postcss@8.5.6)
+ postcss-merge-longhand: 5.1.7(postcss@8.5.6)
+ postcss-merge-rules: 5.1.4(postcss@8.5.6)
+ postcss-minify-font-values: 5.1.0(postcss@8.5.6)
+ postcss-minify-gradients: 5.1.1(postcss@8.5.6)
+ postcss-minify-params: 5.1.4(postcss@8.5.6)
+ postcss-minify-selectors: 5.2.1(postcss@8.5.6)
+ postcss-normalize-charset: 5.1.0(postcss@8.5.6)
+ postcss-normalize-display-values: 5.1.0(postcss@8.5.6)
+ postcss-normalize-positions: 5.1.1(postcss@8.5.6)
+ postcss-normalize-repeat-style: 5.1.1(postcss@8.5.6)
+ postcss-normalize-string: 5.1.0(postcss@8.5.6)
+ postcss-normalize-timing-functions: 5.1.0(postcss@8.5.6)
+ postcss-normalize-unicode: 5.1.1(postcss@8.5.6)
+ postcss-normalize-url: 5.1.0(postcss@8.5.6)
+ postcss-normalize-whitespace: 5.1.1(postcss@8.5.6)
+ postcss-ordered-values: 5.1.3(postcss@8.5.6)
+ postcss-reduce-initial: 5.1.2(postcss@8.5.6)
+ postcss-reduce-transforms: 5.1.0(postcss@8.5.6)
+ postcss-svgo: 5.1.0(postcss@8.5.6)
+ postcss-unique-selectors: 5.1.1(postcss@8.5.6)
+
+ cssnano-preset-default@7.0.3(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ css-declaration-sorter: 7.2.0(postcss@8.5.6)
+ cssnano-utils: 5.0.0(postcss@8.5.6)
+ postcss: 8.5.6
+ postcss-calc: 10.0.0(postcss@8.5.6)
+ postcss-colormin: 7.0.1(postcss@8.5.6)
+ postcss-convert-values: 7.0.1(postcss@8.5.6)
+ postcss-discard-comments: 7.0.1(postcss@8.5.6)
+ postcss-discard-duplicates: 7.0.0(postcss@8.5.6)
+ postcss-discard-empty: 7.0.0(postcss@8.5.6)
+ postcss-discard-overridden: 7.0.0(postcss@8.5.6)
+ postcss-merge-longhand: 7.0.2(postcss@8.5.6)
+ postcss-merge-rules: 7.0.2(postcss@8.5.6)
+ postcss-minify-font-values: 7.0.0(postcss@8.5.6)
+ postcss-minify-gradients: 7.0.0(postcss@8.5.6)
+ postcss-minify-params: 7.0.1(postcss@8.5.6)
+ postcss-minify-selectors: 7.0.2(postcss@8.5.6)
+ postcss-normalize-charset: 7.0.0(postcss@8.5.6)
+ postcss-normalize-display-values: 7.0.0(postcss@8.5.6)
+ postcss-normalize-positions: 7.0.0(postcss@8.5.6)
+ postcss-normalize-repeat-style: 7.0.0(postcss@8.5.6)
+ postcss-normalize-string: 7.0.0(postcss@8.5.6)
+ postcss-normalize-timing-functions: 7.0.0(postcss@8.5.6)
+ postcss-normalize-unicode: 7.0.1(postcss@8.5.6)
+ postcss-normalize-url: 7.0.0(postcss@8.5.6)
+ postcss-normalize-whitespace: 7.0.0(postcss@8.5.6)
+ postcss-ordered-values: 7.0.1(postcss@8.5.6)
+ postcss-reduce-initial: 7.0.1(postcss@8.5.6)
+ postcss-reduce-transforms: 7.0.0(postcss@8.5.6)
+ postcss-svgo: 7.0.1(postcss@8.5.6)
+ postcss-unique-selectors: 7.0.1(postcss@8.5.6)
+
+ cssnano-utils@3.1.0(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
+
+ cssnano-utils@5.0.0(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
+
+ cssnano@5.1.15(postcss@8.5.6):
+ dependencies:
+ cssnano-preset-default: 5.2.14(postcss@8.5.6)
lilconfig: 2.1.0
- postcss: 8.4.39
+ postcss: 8.5.6
yaml: 1.10.2
- cssnano@7.0.3(postcss@8.4.39):
+ cssnano@7.0.3(postcss@8.5.6):
dependencies:
- cssnano-preset-default: 7.0.3(postcss@8.4.39)
+ cssnano-preset-default: 7.0.3(postcss@8.5.6)
lilconfig: 3.1.3
- postcss: 8.4.39
+ postcss: 8.5.6
csso@4.2.0:
dependencies:
@@ -14602,7 +14683,7 @@ snapshots:
domelementtype: 2.3.0
domhandler: 4.3.1
- domutils@3.1.0:
+ domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
@@ -14619,7 +14700,7 @@ snapshots:
dotenv@16.6.1: {}
- dotenv@17.2.1: {}
+ dotenv@17.2.3: {}
dotenv@8.6.0: {}
@@ -14660,7 +14741,7 @@ snapshots:
'@one-ini/wasm': 0.1.1
commander: 10.0.1
minimatch: 9.0.1
- semver: 7.7.2
+ semver: 7.7.3
ee-first@1.1.1: {}
@@ -14669,9 +14750,7 @@ snapshots:
jake: 10.9.4
optional: true
- electron-to-chromium@1.5.227: {}
-
- electron-to-chromium@1.5.228: {}
+ electron-to-chromium@1.5.267: {}
elliptic@6.6.0:
dependencies:
@@ -14707,10 +14786,10 @@ snapshots:
memory-fs: 0.5.0
tapable: 1.1.3
- enhanced-resolve@5.18.3:
+ enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
- tapable: 2.2.3
+ tapable: 2.3.0
ent@2.2.0:
optional: true
@@ -14785,9 +14864,9 @@ snapshots:
es-errors@1.3.0: {}
- es-module-lexer@1.7.0: {}
+ es-module-lexer@2.0.0: {}
- es-object-atoms@1.1.1:
+ es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -14796,7 +14875,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
- hasown: 2.0.2
+ hasown: 2.0.4
es-shim-unscopables@1.0.0:
dependencies:
@@ -14867,7 +14946,7 @@ snapshots:
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.7.3(eslint@8.57.1)(typescript@4.9.5))(eslint-plugin-import@2.28.1)(eslint@8.57.1):
dependencies:
debug: 4.4.1
- enhanced-resolve: 5.18.3
+ enhanced-resolve: 5.18.4
eslint: 8.57.1
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.3(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)
eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.7.3(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)
@@ -14941,7 +15020,7 @@ snapshots:
is-core-module: 2.13.0
minimatch: 3.1.2
resolve: 1.22.6
- semver: 7.7.2
+ semver: 7.7.3
eslint-plugin-node@11.1.0(eslint@8.57.1):
dependencies:
@@ -14981,7 +15060,7 @@ snapshots:
read-pkg-up: 7.0.1
regexp-tree: 0.1.27
safe-regex: 2.1.1
- semver: 7.7.2
+ semver: 7.7.3
strip-indent: 3.0.0
eslint-plugin-vue@9.33.0(eslint@8.57.1):
@@ -15028,15 +15107,15 @@ snapshots:
eslint-visitor-keys@3.4.3: {}
- eslint-webpack-plugin@4.0.1(eslint@8.57.1)(webpack@5.102.0):
+ eslint-webpack-plugin@4.0.1(eslint@8.57.1)(webpack@5.104.1):
dependencies:
'@types/eslint': 8.44.3
eslint: 8.57.1
jest-worker: 29.7.0
micromatch: 4.0.8
normalize-path: 3.0.0
- schema-utils: 4.3.2
- webpack: 5.102.0
+ schema-utils: 4.3.3
+ webpack: 5.104.1
eslint@8.57.1:
dependencies:
@@ -15282,17 +15361,17 @@ snapshots:
schema-utils: 3.3.0
webpack: 4.47.0
- file-loader@6.2.0(webpack@5.102.0):
+ file-loader@6.2.0(webpack@5.104.1):
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
- webpack: 5.102.0
+ webpack: 5.104.1
file-uri-to-path@1.0.0: {}
- filelist@1.0.4:
+ filelist@1.0.6:
dependencies:
- minimatch: 5.1.6
+ minimatch: 5.1.9
optional: true
fill-range@4.0.0:
@@ -15353,18 +15432,12 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
- find-up@7.0.0:
- dependencies:
- locate-path: 7.2.0
- path-exists: 5.0.0
- unicorn-magic: 0.1.0
-
firebase-admin@10.3.0(@firebase/app-types@0.9.2):
dependencies:
'@fastify/busboy': 1.2.1
'@firebase/database-compat': 0.2.10(@firebase/app-types@0.9.2)
'@firebase/database-types': 0.9.17
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
jsonwebtoken: 8.5.1
jwks-rsa: 2.1.5
node-forge: 1.3.1
@@ -15432,7 +15505,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 2.3.8
- follow-redirects@1.15.11: {}
+ follow-redirects@1.16.0: {}
for-each@0.3.3:
dependencies:
@@ -15445,7 +15518,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
- fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.102.0):
+ fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.104.1):
dependencies:
'@babel/code-frame': 7.22.13
'@types/json-schema': 7.0.15
@@ -15458,20 +15531,20 @@ snapshots:
memfs: 3.5.3
minimatch: 3.1.2
schema-utils: 2.7.0
- semver: 7.7.2
+ semver: 7.7.3
tapable: 1.1.3
typescript: 4.9.5
- webpack: 5.102.0
+ webpack: 5.104.1
optionalDependencies:
eslint: 8.57.1
vue-template-compiler: 2.7.16
- form-data@4.0.4:
+ form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
- hasown: 2.0.2
+ hasown: 2.0.4
mime-types: 2.1.35
fraction.js@4.3.7: {}
@@ -15587,12 +15660,12 @@ snapshots:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.2
+ hasown: 2.0.4
math-intrinsics: 1.1.0
get-package-type@0.1.0: {}
@@ -15604,7 +15677,7 @@ snapshots:
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-stream@6.0.1: {}
@@ -15915,7 +15988,7 @@ snapshots:
inherits: 2.0.4
minimalistic-assert: 1.0.1
- hasown@2.0.2:
+ hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -15965,7 +16038,7 @@ snapshots:
entities: 4.5.0
param-case: 3.0.4
relateurl: 0.2.7
- terser: 5.44.0
+ terser: 5.44.1
html-tags@2.0.0: {}
@@ -15995,7 +16068,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
- domutils: 3.1.0
+ domutils: 3.2.2
entities: 4.5.0
http-errors@2.0.0:
@@ -16054,9 +16127,9 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
- icss-utils@5.1.0(postcss@8.4.39):
+ icss-utils@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
idb@7.1.1: {}
@@ -16253,6 +16326,8 @@ snapshots:
is-plain-obj@1.1.0: {}
+ is-plain-obj@4.1.0: {}
+
is-plain-object@2.0.4:
dependencies:
isobject: 3.0.1
@@ -16289,10 +16364,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
- is-text-path@2.0.0:
- dependencies:
- text-extensions: 2.4.0
-
is-typed-array@1.1.12:
dependencies:
which-typed-array: 1.1.11
@@ -16330,7 +16401,7 @@ snapshots:
'@babel/parser': 7.28.4
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
- semver: 7.7.2
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
@@ -16362,7 +16433,7 @@ snapshots:
jake@10.9.4:
dependencies:
async: 3.2.6
- filelist: 1.0.4
+ filelist: 1.0.6
picocolors: 1.1.1
optional: true
@@ -16378,7 +16449,7 @@ snapshots:
'@jest/expect': 30.2.0
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.0
@@ -16398,7 +16469,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@30.2.0(@types/node@24.6.2):
+ jest-cli@30.2.0(@types/node@25.6.0):
dependencies:
'@jest/core': 30.2.0
'@jest/test-result': 30.2.0
@@ -16406,7 +16477,7 @@ snapshots:
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.2.0(@types/node@24.6.2)
+ jest-config: 30.2.0(@types/node@25.6.0)
jest-util: 30.2.0
jest-validate: 30.2.0
yargs: 17.7.2
@@ -16417,7 +16488,7 @@ snapshots:
- supports-color
- ts-node
- jest-config@30.2.0(@types/node@24.6.2):
+ jest-config@30.2.0(@types/node@25.1.0):
dependencies:
'@babel/core': 7.28.4
'@jest/get-type': 30.1.0
@@ -16444,7 +16515,39 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
+ jest-config@30.2.0(@types/node@25.6.0):
+ dependencies:
+ '@babel/core': 7.28.4
+ '@jest/get-type': 30.1.0
+ '@jest/pattern': 30.0.1
+ '@jest/test-sequencer': 30.2.0
+ '@jest/types': 30.2.0
+ babel-jest: 30.2.0(@babel/core@7.28.4)
+ chalk: 4.1.2
+ ci-info: 4.3.0
+ deepmerge: 4.3.1
+ glob: 10.4.5
+ graceful-fs: 4.2.11
+ jest-circus: 30.2.0
+ jest-docblock: 30.2.0
+ jest-environment-node: 30.2.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.2.0
+ jest-runner: 30.2.0
+ jest-util: 30.2.0
+ jest-validate: 30.2.0
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 30.2.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 25.6.0
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -16468,12 +16571,10 @@ snapshots:
jest-util: 30.2.0
pretty-format: 30.2.0
- jest-environment-jsdom@30.2.0:
+ jest-environment-jsdom@30.3.0:
dependencies:
- '@jest/environment': 30.2.0
- '@jest/environment-jsdom-abstract': 30.2.0(jsdom@26.1.0)
- '@types/jsdom': 21.1.7
- '@types/node': 24.6.2
+ '@jest/environment': 30.3.0
+ '@jest/environment-jsdom-abstract': 30.3.0(jsdom@26.1.0)
jsdom: 26.1.0
transitivePeerDependencies:
- bufferutil
@@ -16485,7 +16586,7 @@ snapshots:
'@jest/environment': 30.2.0
'@jest/fake-timers': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
jest-mock: 30.2.0
jest-util: 30.2.0
jest-validate: 30.2.0
@@ -16493,7 +16594,7 @@ snapshots:
jest-haste-map@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -16529,12 +16630,30 @@ snapshots:
slash: 3.0.0
stack-utils: 2.0.6
+ jest-message-util@30.3.0:
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@jest/types': 30.3.0
+ '@types/stack-utils': 2.0.3
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ picomatch: 4.0.4
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ stack-utils: 2.0.6
+
jest-mock@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
jest-util: 30.2.0
+ jest-mock@30.3.0:
+ dependencies:
+ '@jest/types': 30.3.0
+ '@types/node': 25.6.0
+ jest-util: 30.3.0
+
jest-pnp-resolver@1.2.3(jest-resolve@30.2.0):
optionalDependencies:
jest-resolve: 30.2.0
@@ -16566,7 +16685,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
chalk: 4.1.2
emittery: 0.13.1
exit-x: 0.2.2
@@ -16595,7 +16714,7 @@ snapshots:
'@jest/test-result': 30.2.0
'@jest/transform': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
chalk: 4.1.2
cjs-module-lexer: 2.1.0
collect-v8-coverage: 1.0.2
@@ -16634,7 +16753,7 @@ snapshots:
jest-message-util: 30.2.0
jest-util: 30.2.0
pretty-format: 30.2.0
- semver: 7.7.2
+ semver: 7.7.3
synckit: 0.11.11
transitivePeerDependencies:
- supports-color
@@ -16642,21 +16761,30 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
- picomatch: 2.3.1
+ picomatch: 2.3.2
jest-util@30.2.0:
dependencies:
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
chalk: 4.1.2
ci-info: 4.3.0
graceful-fs: 4.2.11
picomatch: 4.0.3
+ jest-util@30.3.0:
+ dependencies:
+ '@jest/types': 30.3.0
+ '@types/node': 25.6.0
+ chalk: 4.1.2
+ ci-info: 4.4.0
+ graceful-fs: 4.2.11
+ picomatch: 4.0.4
+
jest-validate@30.2.0:
dependencies:
'@jest/get-type': 30.1.0
@@ -16670,7 +16798,7 @@ snapshots:
dependencies:
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
- '@types/node': 24.6.2
+ '@types/node': 25.1.0
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -16679,37 +16807,37 @@ snapshots:
jest-worker@26.6.2:
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
merge-stream: 2.0.0
supports-color: 7.2.0
jest-worker@27.5.1:
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@30.2.0:
dependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
'@ungap/structured-clone': 1.3.0
jest-util: 30.2.0
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@30.2.0(@types/node@24.6.2):
+ jest@30.2.0(@types/node@25.6.0):
dependencies:
'@jest/core': 30.2.0
'@jest/types': 30.2.0
import-local: 3.2.0
- jest-cli: 30.2.0(@types/node@24.6.2)
+ jest-cli: 30.2.0(@types/node@25.6.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -16741,7 +16869,7 @@ snapshots:
js-tokens@4.0.0: {}
- js-tokens@9.0.0: {}
+ js-tokens@9.0.1: {}
js-yaml@3.14.1:
dependencies:
@@ -16752,6 +16880,10 @@ snapshots:
dependencies:
argparse: 2.0.1
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
jsdom@26.1.0:
dependencies:
cssstyle: 4.6.0
@@ -16761,7 +16893,7 @@ snapshots:
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
- nwsapi: 2.2.22
+ nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
@@ -16772,7 +16904,7 @@ snapshots:
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
- ws: 8.18.3
+ ws: 8.20.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
@@ -16820,8 +16952,6 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
- jsonparse@1.3.1: {}
-
jsonwebtoken@8.5.1:
dependencies:
jws: 3.2.2
@@ -16874,6 +17004,8 @@ snapshots:
safe-buffer: 5.2.1
optional: true
+ kasi@2.0.1: {}
+
keyv@4.5.3:
dependencies:
json-buffer: 3.0.1
@@ -16919,7 +17051,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- libphonenumber-js@1.12.9: {}
+ libphonenumber-js@1.12.36: {}
lilconfig@2.1.0: {}
@@ -16956,7 +17088,7 @@ snapshots:
loader-runner@2.4.0: {}
- loader-runner@4.3.0: {}
+ loader-runner@4.3.1: {}
loader-utils@1.4.2:
dependencies:
@@ -16990,10 +17122,6 @@ snapshots:
dependencies:
p-locate: 5.0.0
- locate-path@7.2.0:
- dependencies:
- p-locate: 6.0.0
-
lodash._reinterpolate@3.0.0: {}
lodash.camelcase@4.3.0: {}
@@ -17015,7 +17143,8 @@ snapshots:
lodash.isnumber@3.0.3:
optional: true
- lodash.isplainobject@4.0.6: {}
+ lodash.isplainobject@4.0.6:
+ optional: true
lodash.isstring@4.0.1:
optional: true
@@ -17031,10 +17160,6 @@ snapshots:
lodash.once@4.1.1:
optional: true
- lodash.snakecase@4.1.1: {}
-
- lodash.startcase@4.4.0: {}
-
lodash.template@4.5.0:
dependencies:
lodash._reinterpolate: 3.0.0
@@ -17050,8 +17175,6 @@ snapshots:
lodash.uniq@4.5.0: {}
- lodash.upperfirst@4.3.1: {}
-
lodash@4.17.21: {}
log-update@6.1.0:
@@ -17125,7 +17248,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
- semver: 7.7.2
+ semver: 7.7.3
make-error@1.3.6: {}
@@ -17203,6 +17326,8 @@ snapshots:
meow@12.1.1: {}
+ meow@13.2.0: {}
+
merge-source-map@1.1.0:
dependencies:
source-map: 0.6.1
@@ -17283,6 +17408,11 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
+ minimatch@5.1.9:
+ dependencies:
+ brace-expansion: 2.1.1
+ optional: true
+
minimatch@9.0.1:
dependencies:
brace-expansion: 2.0.1
@@ -17353,21 +17483,21 @@ snapshots:
acorn: 8.15.0
pathe: 1.1.2
pkg-types: 1.1.2
- ufo: 1.6.1
+ ufo: 1.6.4
mlly@1.7.1:
dependencies:
acorn: 8.15.0
pathe: 1.1.2
pkg-types: 1.1.2
- ufo: 1.6.1
+ ufo: 1.6.4
mlly@1.7.3:
dependencies:
acorn: 8.15.0
pathe: 1.1.2
pkg-types: 1.2.1
- ufo: 1.6.1
+ ufo: 1.6.4
moment@2.30.1: {}
@@ -17401,6 +17531,8 @@ snapshots:
nanoid@3.3.11: {}
+ nanoid@3.3.12: {}
+
nanoid@3.3.8: {}
nanomatch@1.2.13:
@@ -17483,7 +17615,7 @@ snapshots:
node-object-hash@1.4.2: {}
- node-releases@2.0.21: {}
+ node-releases@2.0.27: {}
node-res@5.0.1:
dependencies:
@@ -17508,7 +17640,7 @@ snapshots:
dependencies:
hosted-git-info: 4.1.0
is-core-module: 2.13.0
- semver: 7.7.2
+ semver: 7.7.3
validate-npm-package-license: 3.0.4
normalize-path@2.1.1:
@@ -17545,10 +17677,10 @@ snapshots:
dependencies:
highlight.js: 11.11.1
- nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16):
+ nuxt@2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(consola@3.2.3)(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16):
dependencies:
'@nuxt/babel-preset-app': 2.18.1(vue@2.7.16)
- '@nuxt/builder': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)
+ '@nuxt/builder': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)
'@nuxt/cli': 2.18.1
'@nuxt/components': 2.2.1(consola@3.2.3)
'@nuxt/config': 2.18.1
@@ -17561,7 +17693,7 @@ snapshots:
'@nuxt/utils': 2.18.1
'@nuxt/vue-app': 2.18.1
'@nuxt/vue-renderer': 2.18.1
- '@nuxt/webpack': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.6.2)(typescript@4.9.5)(vue@2.7.16)
+ '@nuxt/webpack': 2.18.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(prettier@3.8.1)(typescript@4.9.5)(vue@2.7.16)
transitivePeerDependencies:
- '@vue/compiler-sfc'
- arc-templates
@@ -17629,7 +17761,7 @@ snapshots:
- webpack-command
- whiskers
- nwsapi@2.2.22: {}
+ nwsapi@2.2.23: {}
nypm@0.3.9:
dependencies:
@@ -17638,7 +17770,7 @@ snapshots:
execa: 8.0.1
pathe: 1.1.2
pkg-types: 1.2.1
- ufo: 1.6.1
+ ufo: 1.6.4
object-assign@4.1.1: {}
@@ -17731,9 +17863,9 @@ snapshots:
optimize-css-assets-webpack-plugin@6.0.1(webpack@4.47.0):
dependencies:
- cssnano: 5.1.15(postcss@8.4.39)
+ cssnano: 5.1.15(postcss@8.5.6)
last-call-webpack-plugin: 3.0.0
- postcss: 8.4.39
+ postcss: 8.5.6
webpack: 4.47.0
optionator@0.9.3:
@@ -17757,10 +17889,6 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
- p-limit@4.0.0:
- dependencies:
- yocto-queue: 1.2.1
-
p-locate@3.0.0:
dependencies:
p-limit: 2.3.0
@@ -17773,10 +17901,6 @@ snapshots:
dependencies:
p-limit: 3.1.0
- p-locate@6.0.0:
- dependencies:
- p-limit: 4.0.0
-
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
@@ -17857,8 +17981,6 @@ snapshots:
path-exists@4.0.0: {}
- path-exists@5.0.0: {}
-
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@@ -17894,14 +18016,16 @@ snapshots:
picocolors@1.0.0: {}
- picocolors@1.0.1: {}
-
picocolors@1.1.1: {}
picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
+
picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
+
pidtree@0.6.0: {}
pify@2.3.0: {}
@@ -17946,533 +18070,533 @@ snapshots:
posix-character-classes@0.1.1: {}
- postcss-attribute-case-insensitive@6.0.3(postcss@8.4.39):
+ postcss-attribute-case-insensitive@6.0.3(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-calc@10.0.0(postcss@8.4.39):
+ postcss-calc@10.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-value-parser: 4.2.0
- postcss-calc@8.2.4(postcss@8.4.39):
+ postcss-calc@8.2.4(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-value-parser: 4.2.0
- postcss-clamp@4.1.0(postcss@8.4.39):
+ postcss-clamp@4.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-color-functional-notation@6.0.12(postcss@8.4.39):
+ postcss-color-functional-notation@6.0.12(postcss@8.5.6):
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- postcss-color-hex-alpha@9.0.4(postcss@8.4.39):
+ postcss-color-hex-alpha@9.0.4(postcss@8.5.6):
dependencies:
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-color-rebeccapurple@9.0.3(postcss@8.4.39):
+ postcss-color-rebeccapurple@9.0.3(postcss@8.5.6):
dependencies:
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-colormin@5.3.1(postcss@8.4.39):
+ postcss-colormin@5.3.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
colord: 2.9.3
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-colormin@7.0.1(postcss@8.4.39):
+ postcss-colormin@7.0.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
colord: 2.9.3
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-convert-values@5.1.3(postcss@8.4.39):
+ postcss-convert-values@5.1.3(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-convert-values@7.0.1(postcss@8.4.39):
+ postcss-convert-values@7.0.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-custom-media@10.0.7(postcss@8.4.39):
+ postcss-custom-media@10.0.7(postcss@8.5.6):
dependencies:
'@csstools/cascade-layer-name-parser': 1.0.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
'@csstools/media-query-list-parser': 2.1.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-custom-properties@13.3.11(postcss@8.4.39):
+ postcss-custom-properties@13.3.11(postcss@8.5.6):
dependencies:
'@csstools/cascade-layer-name-parser': 1.0.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-custom-selectors@7.1.11(postcss@8.4.39):
+ postcss-custom-selectors@7.1.11(postcss@8.5.6):
dependencies:
'@csstools/cascade-layer-name-parser': 1.0.12(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-dir-pseudo-class@8.0.1(postcss@8.4.39):
+ postcss-dir-pseudo-class@8.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-discard-comments@5.1.2(postcss@8.4.39):
+ postcss-discard-comments@5.1.2(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-comments@7.0.1(postcss@8.4.39):
+ postcss-discard-comments@7.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-discard-duplicates@5.1.0(postcss@8.4.39):
+ postcss-discard-duplicates@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-duplicates@7.0.0(postcss@8.4.39):
+ postcss-discard-duplicates@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-empty@5.1.1(postcss@8.4.39):
+ postcss-discard-empty@5.1.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-empty@7.0.0(postcss@8.4.39):
+ postcss-discard-empty@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-overridden@5.1.0(postcss@8.4.39):
+ postcss-discard-overridden@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-discard-overridden@7.0.0(postcss@8.4.39):
+ postcss-discard-overridden@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-double-position-gradients@5.0.6(postcss@8.4.39):
+ postcss-double-position-gradients@5.0.6(postcss@8.5.6):
dependencies:
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-focus-visible@9.0.1(postcss@8.4.39):
+ postcss-focus-visible@9.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-focus-within@8.0.1(postcss@8.4.39):
+ postcss-focus-within@8.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-font-variant@5.0.0(postcss@8.4.39):
+ postcss-font-variant@5.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-gap-properties@5.0.1(postcss@8.4.39):
+ postcss-gap-properties@5.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-html@1.7.0:
+ postcss-html@1.8.1:
dependencies:
htmlparser2: 8.0.2
- js-tokens: 9.0.0
- postcss: 8.4.35
- postcss-safe-parser: 6.0.0(postcss@8.4.35)
+ js-tokens: 9.0.1
+ postcss: 8.5.6
+ postcss-safe-parser: 6.0.0(postcss@8.5.6)
- postcss-image-set-function@6.0.3(postcss@8.4.39):
+ postcss-image-set-function@6.0.3(postcss@8.5.6):
dependencies:
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
postcss-import-resolver@2.0.0:
dependencies:
enhanced-resolve: 4.5.0
- postcss-import@15.1.0(postcss@8.4.39):
+ postcss-import@15.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.6
- postcss-lab-function@6.0.17(postcss@8.4.39):
+ postcss-lab-function@6.0.17(postcss@8.5.6):
dependencies:
'@csstools/css-color-parser': 2.0.3(@csstools/css-parser-algorithms@2.7.0(@csstools/css-tokenizer@2.3.2))(@csstools/css-tokenizer@2.3.2)
'@csstools/css-parser-algorithms': 2.7.0(@csstools/css-tokenizer@2.3.2)
'@csstools/css-tokenizer': 2.3.2
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/utilities': 1.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/utilities': 1.0.0(postcss@8.5.6)
+ postcss: 8.5.6
- postcss-loader@4.3.0(postcss@8.4.39)(webpack@4.47.0):
+ postcss-loader@4.3.0(postcss@8.5.6)(webpack@4.47.0):
dependencies:
cosmiconfig: 7.1.0
klona: 2.0.6
loader-utils: 2.0.4
- postcss: 8.4.39
+ postcss: 8.5.6
schema-utils: 3.3.0
- semver: 7.7.2
+ semver: 7.7.3
webpack: 4.47.0
- postcss-logical@7.0.1(postcss@8.4.39):
+ postcss-logical@7.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-merge-longhand@5.1.7(postcss@8.4.39):
+ postcss-merge-longhand@5.1.7(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- stylehacks: 5.1.1(postcss@8.4.39)
+ stylehacks: 5.1.1(postcss@8.5.6)
- postcss-merge-longhand@7.0.2(postcss@8.4.39):
+ postcss-merge-longhand@7.0.2(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- stylehacks: 7.0.2(postcss@8.4.39)
+ stylehacks: 7.0.2(postcss@8.5.6)
- postcss-merge-rules@5.1.4(postcss@8.4.39):
+ postcss-merge-rules@5.1.4(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
- cssnano-utils: 3.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 3.1.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-merge-rules@7.0.2(postcss@8.4.39):
+ postcss-merge-rules@7.0.2(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
- cssnano-utils: 5.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 5.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-minify-font-values@5.1.0(postcss@8.4.39):
+ postcss-minify-font-values@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-font-values@7.0.0(postcss@8.4.39):
+ postcss-minify-font-values@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-gradients@5.1.1(postcss@8.4.39):
+ postcss-minify-gradients@5.1.1(postcss@8.5.6):
dependencies:
colord: 2.9.3
- cssnano-utils: 3.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 3.1.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-gradients@7.0.0(postcss@8.4.39):
+ postcss-minify-gradients@7.0.0(postcss@8.5.6):
dependencies:
colord: 2.9.3
- cssnano-utils: 5.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 5.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-params@5.1.4(postcss@8.4.39):
+ postcss-minify-params@5.1.4(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- cssnano-utils: 3.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ browserslist: 4.28.1
+ cssnano-utils: 3.1.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-params@7.0.1(postcss@8.4.39):
+ postcss-minify-params@7.0.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- cssnano-utils: 5.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ browserslist: 4.28.1
+ cssnano-utils: 5.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-minify-selectors@5.2.1(postcss@8.4.39):
+ postcss-minify-selectors@5.2.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-minify-selectors@7.0.2(postcss@8.4.39):
+ postcss-minify-selectors@7.0.2(postcss@8.5.6):
dependencies:
cssesc: 3.0.0
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-modules-extract-imports@3.0.0(postcss@8.4.39):
+ postcss-modules-extract-imports@3.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-modules-local-by-default@4.0.3(postcss@8.4.39):
+ postcss-modules-local-by-default@4.0.3(postcss@8.5.6):
dependencies:
- icss-utils: 5.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ icss-utils: 5.1.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-value-parser: 4.2.0
- postcss-modules-scope@3.0.0(postcss@8.4.39):
+ postcss-modules-scope@3.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-modules-values@4.0.0(postcss@8.4.39):
+ postcss-modules-values@4.0.0(postcss@8.5.6):
dependencies:
- icss-utils: 5.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ icss-utils: 5.1.0(postcss@8.5.6)
+ postcss: 8.5.6
- postcss-nesting@12.1.5(postcss@8.4.39):
+ postcss-nesting@12.1.5(postcss@8.5.6):
dependencies:
'@csstools/selector-resolve-nested': 1.1.0(postcss-selector-parser@6.1.2)
'@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2)
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-normalize-charset@5.1.0(postcss@8.4.39):
+ postcss-normalize-charset@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-normalize-charset@7.0.0(postcss@8.4.39):
+ postcss-normalize-charset@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-normalize-display-values@5.1.0(postcss@8.4.39):
+ postcss-normalize-display-values@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-display-values@7.0.0(postcss@8.4.39):
+ postcss-normalize-display-values@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-positions@5.1.1(postcss@8.4.39):
+ postcss-normalize-positions@5.1.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-positions@7.0.0(postcss@8.4.39):
+ postcss-normalize-positions@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-repeat-style@5.1.1(postcss@8.4.39):
+ postcss-normalize-repeat-style@5.1.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-repeat-style@7.0.0(postcss@8.4.39):
+ postcss-normalize-repeat-style@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-string@5.1.0(postcss@8.4.39):
+ postcss-normalize-string@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-string@7.0.0(postcss@8.4.39):
+ postcss-normalize-string@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-timing-functions@5.1.0(postcss@8.4.39):
+ postcss-normalize-timing-functions@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-timing-functions@7.0.0(postcss@8.4.39):
+ postcss-normalize-timing-functions@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-unicode@5.1.1(postcss@8.4.39):
+ postcss-normalize-unicode@5.1.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-unicode@7.0.1(postcss@8.4.39):
+ postcss-normalize-unicode@7.0.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-url@5.1.0(postcss@8.4.39):
+ postcss-normalize-url@5.1.0(postcss@8.5.6):
dependencies:
normalize-url: 6.1.0
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-url@7.0.0(postcss@8.4.39):
+ postcss-normalize-url@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-whitespace@5.1.1(postcss@8.4.39):
+ postcss-normalize-whitespace@5.1.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-normalize-whitespace@7.0.0(postcss@8.4.39):
+ postcss-normalize-whitespace@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-opacity-percentage@2.0.0(postcss@8.4.39):
+ postcss-opacity-percentage@2.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-ordered-values@5.1.3(postcss@8.4.39):
+ postcss-ordered-values@5.1.3(postcss@8.5.6):
dependencies:
- cssnano-utils: 3.1.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 3.1.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-ordered-values@7.0.1(postcss@8.4.39):
+ postcss-ordered-values@7.0.1(postcss@8.5.6):
dependencies:
- cssnano-utils: 5.0.0(postcss@8.4.39)
- postcss: 8.4.39
+ cssnano-utils: 5.0.0(postcss@8.5.6)
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-overflow-shorthand@5.0.1(postcss@8.4.39):
+ postcss-overflow-shorthand@5.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-page-break@3.0.4(postcss@8.4.39):
+ postcss-page-break@3.0.4(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-place@9.0.1(postcss@8.4.39):
+ postcss-place@9.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-preset-env@9.5.15(postcss@8.4.39):
- dependencies:
- '@csstools/postcss-cascade-layers': 4.0.6(postcss@8.4.39)
- '@csstools/postcss-color-function': 3.0.17(postcss@8.4.39)
- '@csstools/postcss-color-mix-function': 2.0.17(postcss@8.4.39)
- '@csstools/postcss-exponential-functions': 1.0.8(postcss@8.4.39)
- '@csstools/postcss-font-format-keywords': 3.0.2(postcss@8.4.39)
- '@csstools/postcss-gamut-mapping': 1.0.10(postcss@8.4.39)
- '@csstools/postcss-gradients-interpolation-method': 4.0.18(postcss@8.4.39)
- '@csstools/postcss-hwb-function': 3.0.16(postcss@8.4.39)
- '@csstools/postcss-ic-unit': 3.0.6(postcss@8.4.39)
- '@csstools/postcss-initial': 1.0.1(postcss@8.4.39)
- '@csstools/postcss-is-pseudo-class': 4.0.8(postcss@8.4.39)
- '@csstools/postcss-light-dark-function': 1.0.6(postcss@8.4.39)
- '@csstools/postcss-logical-float-and-clear': 2.0.1(postcss@8.4.39)
- '@csstools/postcss-logical-overflow': 1.0.1(postcss@8.4.39)
- '@csstools/postcss-logical-overscroll-behavior': 1.0.1(postcss@8.4.39)
- '@csstools/postcss-logical-resize': 2.0.1(postcss@8.4.39)
- '@csstools/postcss-logical-viewport-units': 2.0.10(postcss@8.4.39)
- '@csstools/postcss-media-minmax': 1.1.7(postcss@8.4.39)
- '@csstools/postcss-media-queries-aspect-ratio-number-values': 2.0.10(postcss@8.4.39)
- '@csstools/postcss-nested-calc': 3.0.2(postcss@8.4.39)
- '@csstools/postcss-normalize-display-values': 3.0.2(postcss@8.4.39)
- '@csstools/postcss-oklab-function': 3.0.17(postcss@8.4.39)
- '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.4.39)
- '@csstools/postcss-relative-color-syntax': 2.0.17(postcss@8.4.39)
- '@csstools/postcss-scope-pseudo-class': 3.0.1(postcss@8.4.39)
- '@csstools/postcss-stepped-value-functions': 3.0.9(postcss@8.4.39)
- '@csstools/postcss-text-decoration-shorthand': 3.0.7(postcss@8.4.39)
- '@csstools/postcss-trigonometric-functions': 3.0.9(postcss@8.4.39)
- '@csstools/postcss-unset-value': 3.0.1(postcss@8.4.39)
- autoprefixer: 10.4.19(postcss@8.4.39)
- browserslist: 4.26.2
- css-blank-pseudo: 6.0.2(postcss@8.4.39)
- css-has-pseudo: 6.0.5(postcss@8.4.39)
- css-prefers-color-scheme: 9.0.1(postcss@8.4.39)
+ postcss-preset-env@9.5.15(postcss@8.5.6):
+ dependencies:
+ '@csstools/postcss-cascade-layers': 4.0.6(postcss@8.5.6)
+ '@csstools/postcss-color-function': 3.0.17(postcss@8.5.6)
+ '@csstools/postcss-color-mix-function': 2.0.17(postcss@8.5.6)
+ '@csstools/postcss-exponential-functions': 1.0.8(postcss@8.5.6)
+ '@csstools/postcss-font-format-keywords': 3.0.2(postcss@8.5.6)
+ '@csstools/postcss-gamut-mapping': 1.0.10(postcss@8.5.6)
+ '@csstools/postcss-gradients-interpolation-method': 4.0.18(postcss@8.5.6)
+ '@csstools/postcss-hwb-function': 3.0.16(postcss@8.5.6)
+ '@csstools/postcss-ic-unit': 3.0.6(postcss@8.5.6)
+ '@csstools/postcss-initial': 1.0.1(postcss@8.5.6)
+ '@csstools/postcss-is-pseudo-class': 4.0.8(postcss@8.5.6)
+ '@csstools/postcss-light-dark-function': 1.0.6(postcss@8.5.6)
+ '@csstools/postcss-logical-float-and-clear': 2.0.1(postcss@8.5.6)
+ '@csstools/postcss-logical-overflow': 1.0.1(postcss@8.5.6)
+ '@csstools/postcss-logical-overscroll-behavior': 1.0.1(postcss@8.5.6)
+ '@csstools/postcss-logical-resize': 2.0.1(postcss@8.5.6)
+ '@csstools/postcss-logical-viewport-units': 2.0.10(postcss@8.5.6)
+ '@csstools/postcss-media-minmax': 1.1.7(postcss@8.5.6)
+ '@csstools/postcss-media-queries-aspect-ratio-number-values': 2.0.10(postcss@8.5.6)
+ '@csstools/postcss-nested-calc': 3.0.2(postcss@8.5.6)
+ '@csstools/postcss-normalize-display-values': 3.0.2(postcss@8.5.6)
+ '@csstools/postcss-oklab-function': 3.0.17(postcss@8.5.6)
+ '@csstools/postcss-progressive-custom-properties': 3.2.0(postcss@8.5.6)
+ '@csstools/postcss-relative-color-syntax': 2.0.17(postcss@8.5.6)
+ '@csstools/postcss-scope-pseudo-class': 3.0.1(postcss@8.5.6)
+ '@csstools/postcss-stepped-value-functions': 3.0.9(postcss@8.5.6)
+ '@csstools/postcss-text-decoration-shorthand': 3.0.7(postcss@8.5.6)
+ '@csstools/postcss-trigonometric-functions': 3.0.9(postcss@8.5.6)
+ '@csstools/postcss-unset-value': 3.0.1(postcss@8.5.6)
+ autoprefixer: 10.4.19(postcss@8.5.6)
+ browserslist: 4.28.1
+ css-blank-pseudo: 6.0.2(postcss@8.5.6)
+ css-has-pseudo: 6.0.5(postcss@8.5.6)
+ css-prefers-color-scheme: 9.0.1(postcss@8.5.6)
cssdb: 8.0.2
- postcss: 8.4.39
- postcss-attribute-case-insensitive: 6.0.3(postcss@8.4.39)
- postcss-clamp: 4.1.0(postcss@8.4.39)
- postcss-color-functional-notation: 6.0.12(postcss@8.4.39)
- postcss-color-hex-alpha: 9.0.4(postcss@8.4.39)
- postcss-color-rebeccapurple: 9.0.3(postcss@8.4.39)
- postcss-custom-media: 10.0.7(postcss@8.4.39)
- postcss-custom-properties: 13.3.11(postcss@8.4.39)
- postcss-custom-selectors: 7.1.11(postcss@8.4.39)
- postcss-dir-pseudo-class: 8.0.1(postcss@8.4.39)
- postcss-double-position-gradients: 5.0.6(postcss@8.4.39)
- postcss-focus-visible: 9.0.1(postcss@8.4.39)
- postcss-focus-within: 8.0.1(postcss@8.4.39)
- postcss-font-variant: 5.0.0(postcss@8.4.39)
- postcss-gap-properties: 5.0.1(postcss@8.4.39)
- postcss-image-set-function: 6.0.3(postcss@8.4.39)
- postcss-lab-function: 6.0.17(postcss@8.4.39)
- postcss-logical: 7.0.1(postcss@8.4.39)
- postcss-nesting: 12.1.5(postcss@8.4.39)
- postcss-opacity-percentage: 2.0.0(postcss@8.4.39)
- postcss-overflow-shorthand: 5.0.1(postcss@8.4.39)
- postcss-page-break: 3.0.4(postcss@8.4.39)
- postcss-place: 9.0.1(postcss@8.4.39)
- postcss-pseudo-class-any-link: 9.0.2(postcss@8.4.39)
- postcss-replace-overflow-wrap: 4.0.0(postcss@8.4.39)
- postcss-selector-not: 7.0.2(postcss@8.4.39)
-
- postcss-pseudo-class-any-link@9.0.2(postcss@8.4.39):
- dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
+ postcss-attribute-case-insensitive: 6.0.3(postcss@8.5.6)
+ postcss-clamp: 4.1.0(postcss@8.5.6)
+ postcss-color-functional-notation: 6.0.12(postcss@8.5.6)
+ postcss-color-hex-alpha: 9.0.4(postcss@8.5.6)
+ postcss-color-rebeccapurple: 9.0.3(postcss@8.5.6)
+ postcss-custom-media: 10.0.7(postcss@8.5.6)
+ postcss-custom-properties: 13.3.11(postcss@8.5.6)
+ postcss-custom-selectors: 7.1.11(postcss@8.5.6)
+ postcss-dir-pseudo-class: 8.0.1(postcss@8.5.6)
+ postcss-double-position-gradients: 5.0.6(postcss@8.5.6)
+ postcss-focus-visible: 9.0.1(postcss@8.5.6)
+ postcss-focus-within: 8.0.1(postcss@8.5.6)
+ postcss-font-variant: 5.0.0(postcss@8.5.6)
+ postcss-gap-properties: 5.0.1(postcss@8.5.6)
+ postcss-image-set-function: 6.0.3(postcss@8.5.6)
+ postcss-lab-function: 6.0.17(postcss@8.5.6)
+ postcss-logical: 7.0.1(postcss@8.5.6)
+ postcss-nesting: 12.1.5(postcss@8.5.6)
+ postcss-opacity-percentage: 2.0.0(postcss@8.5.6)
+ postcss-overflow-shorthand: 5.0.1(postcss@8.5.6)
+ postcss-page-break: 3.0.4(postcss@8.5.6)
+ postcss-place: 9.0.1(postcss@8.5.6)
+ postcss-pseudo-class-any-link: 9.0.2(postcss@8.5.6)
+ postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.6)
+ postcss-selector-not: 7.0.2(postcss@8.5.6)
+
+ postcss-pseudo-class-any-link@9.0.2(postcss@8.5.6):
+ dependencies:
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-reduce-initial@5.1.2(postcss@8.4.39):
+ postcss-reduce-initial@5.1.2(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-reduce-initial@7.0.1(postcss@8.4.39):
+ postcss-reduce-initial@7.0.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
caniuse-api: 3.0.0
- postcss: 8.4.39
+ postcss: 8.5.6
- postcss-reduce-transforms@5.1.0(postcss@8.4.39):
+ postcss-reduce-transforms@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-reduce-transforms@7.0.0(postcss@8.4.39):
+ postcss-reduce-transforms@7.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
- postcss-replace-overflow-wrap@4.0.0(postcss@8.4.39):
+ postcss-replace-overflow-wrap@4.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-resolve-nested-selector@0.1.1: {}
@@ -18480,13 +18604,13 @@ snapshots:
dependencies:
postcss: 8.4.31
- postcss-safe-parser@6.0.0(postcss@8.4.35):
+ postcss-safe-parser@6.0.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.35
+ postcss: 8.5.6
- postcss-selector-not@7.0.2(postcss@8.4.39):
+ postcss-selector-not@7.0.2(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.13:
@@ -18499,34 +18623,34 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss-svgo@5.1.0(postcss@8.4.39):
+ postcss-svgo@5.1.0(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
svgo: 2.8.0
- postcss-svgo@7.0.1(postcss@8.4.39):
+ postcss-svgo@7.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
svgo: 3.3.2
- postcss-unique-selectors@5.1.1(postcss@8.4.39):
+ postcss-unique-selectors@5.1.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-unique-selectors@7.0.1(postcss@8.4.39):
+ postcss-unique-selectors@7.0.1(postcss@8.5.6):
dependencies:
- postcss: 8.4.39
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- postcss-url@10.1.3(postcss@8.4.39):
+ postcss-url@10.1.3(postcss@8.5.6):
dependencies:
make-dir: 3.1.0
mime: 2.5.2
minimatch: 3.0.8
- postcss: 8.4.39
+ postcss: 8.5.6
xxhashjs: 0.2.2
postcss-value-parser@4.2.0: {}
@@ -18542,17 +18666,11 @@ snapshots:
picocolors: 1.0.0
source-map-js: 1.0.2
- postcss@8.4.35:
- dependencies:
- nanoid: 3.3.8
- picocolors: 1.0.0
- source-map-js: 1.0.2
-
- postcss@8.4.39:
+ postcss@8.5.15:
dependencies:
- nanoid: 3.3.8
- picocolors: 1.0.1
- source-map-js: 1.2.0
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
@@ -18567,7 +18685,7 @@ snapshots:
prettier@2.8.8:
optional: true
- prettier@3.6.2: {}
+ prettier@3.8.1: {}
pretty-bytes@5.6.0: {}
@@ -18582,6 +18700,12 @@ snapshots:
ansi-styles: 5.2.0
react-is: 18.3.1
+ pretty-format@30.3.0:
+ dependencies:
+ '@jest/schemas': 30.0.5
+ ansi-styles: 5.2.0
+ react-is: 18.3.1
+
pretty-time@1.1.0: {}
pretty@2.0.0:
@@ -18608,7 +18732,7 @@ snapshots:
proto3-json-serializer@0.1.9:
dependencies:
- protobufjs: 6.11.4
+ protobufjs: 6.11.6
optional: true
protobufjs@6.11.3:
@@ -18624,11 +18748,11 @@ snapshots:
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/long': 4.0.2
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
long: 4.0.0
optional: true
- protobufjs@6.11.4:
+ protobufjs@6.11.6:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
@@ -18641,23 +18765,23 @@ snapshots:
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/long': 4.0.2
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
long: 4.0.0
optional: true
- protobufjs@7.2.5:
+ protobufjs@7.5.8:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
- '@protobufjs/codegen': 2.0.4
+ '@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
- '@protobufjs/inquire': 1.1.0
+ '@protobufjs/inquire': 1.1.1
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
- '@protobufjs/utf8': 1.1.0
- '@types/node': 24.6.2
+ '@protobufjs/utf8': 1.1.1
+ '@types/node': 25.6.0
long: 5.2.3
protocols@2.0.1: {}
@@ -18950,7 +19074,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
- rollup@3.29.5:
+ rollup@3.30.0:
optionalDependencies:
fsevents: 2.3.3
@@ -18997,14 +19121,14 @@ snapshots:
safer-buffer@2.1.2: {}
- sass-loader@10.4.1(sass@1.32.13)(webpack@5.102.0):
+ sass-loader@10.4.1(sass@1.32.13)(webpack@5.104.1):
dependencies:
klona: 2.0.6
loader-utils: 2.0.4
neo-async: 2.6.2
schema-utils: 3.3.0
- semver: 7.7.2
- webpack: 5.102.0
+ semver: 7.7.3
+ webpack: 5.104.1
optionalDependencies:
sass: 1.32.13
@@ -19042,7 +19166,7 @@ snapshots:
ajv: 6.12.6
ajv-keywords: 3.5.2(ajv@6.12.6)
- schema-utils@4.3.2:
+ schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
ajv: 8.17.1
@@ -19065,6 +19189,8 @@ snapshots:
semver@7.7.2: {}
+ semver@7.7.3: {}
+
send@0.19.0:
dependencies:
debug: 2.6.9
@@ -19227,8 +19353,6 @@ snapshots:
source-map-js@1.0.2: {}
- source-map-js@1.2.0: {}
-
source-map-js@1.2.1: {}
source-map-resolve@0.5.3:
@@ -19430,7 +19554,7 @@ snapshots:
strip-literal@2.1.0:
dependencies:
- js-tokens: 9.0.0
+ js-tokens: 9.0.1
stubs@3.0.0:
optional: true
@@ -19445,33 +19569,33 @@ snapshots:
style-search@0.1.0: {}
- stylehacks@5.1.1(postcss@8.4.39):
+ stylehacks@5.1.1(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- stylehacks@7.0.2(postcss@8.4.39):
+ stylehacks@7.0.2(postcss@8.5.6):
dependencies:
- browserslist: 4.26.3
- postcss: 8.4.39
+ browserslist: 4.28.1
+ postcss: 8.5.6
postcss-selector-parser: 6.1.2
- stylelint-config-html@1.1.0(postcss-html@1.7.0)(stylelint@15.11.0(typescript@4.9.5)):
+ stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@15.11.0(typescript@4.9.5)):
dependencies:
- postcss-html: 1.7.0
+ postcss-html: 1.8.1
stylelint: 15.11.0(typescript@4.9.5)
stylelint-config-prettier@9.0.5(stylelint@15.11.0(typescript@4.9.5)):
dependencies:
stylelint: 15.11.0(typescript@4.9.5)
- stylelint-config-recommended-vue@1.5.0(postcss-html@1.7.0)(stylelint@15.11.0(typescript@4.9.5)):
+ stylelint-config-recommended-vue@1.5.0(postcss-html@1.8.1)(stylelint@15.11.0(typescript@4.9.5)):
dependencies:
- postcss-html: 1.7.0
+ postcss-html: 1.8.1
semver: 7.5.4
stylelint: 15.11.0(typescript@4.9.5)
- stylelint-config-html: 1.1.0(postcss-html@1.7.0)(stylelint@15.11.0(typescript@4.9.5))
+ stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@15.11.0(typescript@4.9.5))
stylelint-config-recommended: 13.0.0(stylelint@15.11.0(typescript@4.9.5))
stylelint-config-recommended@13.0.0(stylelint@15.11.0(typescript@4.9.5)):
@@ -19483,15 +19607,15 @@ snapshots:
stylelint: 15.11.0(typescript@4.9.5)
stylelint-config-recommended: 13.0.0(stylelint@15.11.0(typescript@4.9.5))
- stylelint-webpack-plugin@5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.102.0):
+ stylelint-webpack-plugin@5.0.1(stylelint@15.11.0(typescript@4.9.5))(webpack@5.104.1):
dependencies:
globby: 11.1.0
jest-worker: 29.7.0
micromatch: 4.0.8
normalize-path: 3.0.0
- schema-utils: 4.3.2
+ schema-utils: 4.3.3
stylelint: 15.11.0(typescript@4.9.5)
- webpack: 5.102.0
+ webpack: 5.104.1
stylelint@15.11.0(typescript@4.9.5):
dependencies:
@@ -19598,7 +19722,7 @@ snapshots:
tapable@1.1.3: {}
- tapable@2.2.3: {}
+ tapable@2.3.0: {}
tar@6.2.0:
dependencies:
@@ -19643,20 +19767,20 @@ snapshots:
schema-utils: 3.3.0
serialize-javascript: 5.0.1
source-map: 0.6.1
- terser: 5.44.0
+ terser: 5.44.1
webpack: 4.47.0
webpack-sources: 1.4.3
transitivePeerDependencies:
- bluebird
- terser-webpack-plugin@5.3.14(webpack@5.102.0):
+ terser-webpack-plugin@5.3.16(webpack@5.104.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
- schema-utils: 4.3.2
+ schema-utils: 4.3.3
serialize-javascript: 6.0.2
- terser: 5.44.0
- webpack: 5.102.0
+ terser: 5.44.1
+ webpack: 5.104.1
terser@4.8.1:
dependencies:
@@ -19665,7 +19789,7 @@ snapshots:
source-map: 0.6.1
source-map-support: 0.5.21
- terser@5.44.0:
+ terser@5.44.1:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
@@ -19681,8 +19805,6 @@ snapshots:
text-decoding@1.0.0:
optional: true
- text-extensions@2.4.0: {}
-
text-table@0.2.0: {}
thingies@1.21.0(tslib@2.6.2):
@@ -19692,7 +19814,7 @@ snapshots:
thread-loader@3.0.4(webpack@4.47.0):
dependencies:
json-parse-better-errors: 1.0.2
- loader-runner: 4.3.0
+ loader-runner: 4.3.1
loader-utils: 2.0.4
neo-async: 2.6.2
schema-utils: 3.3.0
@@ -19713,7 +19835,7 @@ snapshots:
dependencies:
setimmediate: 1.0.5
- tinyexec@1.0.1: {}
+ tinyexec@1.0.2: {}
tldts-core@6.1.86: {}
@@ -19775,35 +19897,35 @@ snapshots:
dependencies:
typescript: 4.9.5
- ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@24.6.2))(typescript@4.9.5):
+ ts-jest@29.4.6(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.3.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.3.0)(jest@30.2.0(@types/node@25.6.0))(typescript@4.9.5):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 30.2.0(@types/node@24.6.2)
+ jest: 30.2.0(@types/node@25.6.0)
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
- semver: 7.7.2
+ semver: 7.7.3
type-fest: 4.41.0
typescript: 4.9.5
yargs-parser: 21.1.1
optionalDependencies:
'@babel/core': 7.28.4
'@jest/transform': 30.2.0
- '@jest/types': 30.2.0
+ '@jest/types': 30.3.0
babel-jest: 30.2.0(@babel/core@7.28.4)
- jest-util: 30.2.0
+ jest-util: 30.3.0
- ts-loader@8.4.0(typescript@4.9.5)(webpack@5.102.0):
+ ts-loader@8.4.0(typescript@4.9.5)(webpack@5.104.1):
dependencies:
chalk: 4.1.2
enhanced-resolve: 4.5.0
loader-utils: 2.0.4
micromatch: 4.0.8
- semver: 7.7.2
+ semver: 7.7.3
typescript: 4.9.5
- webpack: 5.102.0
+ webpack: 5.104.1
ts-pnp@1.2.0(typescript@4.9.5):
optionalDependencies:
@@ -19890,7 +20012,7 @@ snapshots:
ua-parser-js@1.0.38: {}
- ufo@1.6.1: {}
+ ufo@1.6.4: {}
uglify-js@3.19.3:
optional: true
@@ -19911,7 +20033,9 @@ snapshots:
magic-string: 0.30.10
unplugin: 1.11.0
- undici-types@7.13.0: {}
+ undici-types@7.16.0: {}
+
+ undici-types@7.19.2: {}
undici@6.19.7: {}
@@ -19930,9 +20054,9 @@ snapshots:
unicorn-magic@0.1.0: {}
- unimport@3.4.0(rollup@3.29.5):
+ unimport@3.4.0(rollup@3.30.0):
dependencies:
- '@rollup/pluginutils': 5.0.4(rollup@3.29.5)
+ '@rollup/pluginutils': 5.0.4(rollup@3.30.0)
escape-string-regexp: 5.0.0
fast-glob: 3.3.1
local-pkg: 0.4.3
@@ -19946,9 +20070,9 @@ snapshots:
transitivePeerDependencies:
- rollup
- unimport@3.7.2(rollup@3.29.5):
+ unimport@3.7.2(rollup@3.30.0):
dependencies:
- '@rollup/pluginutils': 5.1.0(rollup@3.29.5)
+ '@rollup/pluginutils': 5.1.0(rollup@3.30.0)
acorn: 8.15.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
@@ -20062,15 +20186,9 @@ snapshots:
upath@2.0.1: {}
- update-browserslist-db@1.1.3(browserslist@4.26.2):
- dependencies:
- browserslist: 4.26.2
- escalade: 3.2.0
- picocolors: 1.1.1
-
- update-browserslist-db@1.1.3(browserslist@4.26.3):
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
- browserslist: 4.26.3
+ browserslist: 4.28.1
escalade: 3.2.0
picocolors: 1.1.1
@@ -20080,14 +20198,14 @@ snapshots:
urix@0.1.0: {}
- url-loader@4.1.1(file-loader@6.2.0(webpack@5.102.0))(webpack@4.47.0):
+ url-loader@4.1.1(file-loader@6.2.0(webpack@5.104.1))(webpack@4.47.0):
dependencies:
loader-utils: 2.0.4
mime-types: 2.1.35
schema-utils: 3.3.0
webpack: 4.47.0
optionalDependencies:
- file-loader: 6.2.0(webpack@5.102.0)
+ file-loader: 6.2.0(webpack@5.104.1)
url@0.11.3:
dependencies:
@@ -20131,43 +20249,43 @@ snapshots:
vary@1.1.2: {}
- vite-plugin-eslint@1.8.1(eslint@8.57.1)(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0)):
+ vite-plugin-eslint@1.8.1(eslint@8.57.1)(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1)):
dependencies:
'@rollup/pluginutils': 4.2.1
'@types/eslint': 8.44.3
eslint: 8.57.1
rollup: 2.79.2
- vite: 4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0)
+ vite: 4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1)
- vite-plugin-stylelint@5.3.1(postcss@8.4.39)(rollup@3.29.5)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0)):
+ vite-plugin-stylelint@5.3.1(postcss@8.5.6)(rollup@3.30.0)(stylelint@15.11.0(typescript@4.9.5))(vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1)):
dependencies:
- '@rollup/pluginutils': 5.1.0(rollup@3.29.5)
+ '@rollup/pluginutils': 5.1.0(rollup@3.30.0)
chokidar: 3.6.0
debug: 4.4.1
stylelint: 15.11.0(typescript@4.9.5)
- vite: 4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0)
+ vite: 4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1)
optionalDependencies:
- postcss: 8.4.39
- rollup: 3.29.5
+ postcss: 8.5.6
+ rollup: 3.30.0
transitivePeerDependencies:
- supports-color
- vite@4.5.3(@types/node@24.6.2)(sass@1.32.13)(terser@5.44.0):
+ vite@4.5.3(@types/node@25.6.0)(sass@1.32.13)(terser@5.44.1):
dependencies:
esbuild: 0.18.20
- postcss: 8.5.6
- rollup: 3.29.5
+ postcss: 8.5.15
+ rollup: 3.30.0
optionalDependencies:
- '@types/node': 24.6.2
+ '@types/node': 25.6.0
fsevents: 2.3.3
sass: 1.32.13
- terser: 5.44.0
+ terser: 5.44.1
vm-browserify@1.1.2: {}
- vue-chartjs@5.3.2(chart.js@4.5.0)(vue@2.7.16):
+ vue-chartjs@5.3.3(chart.js@4.5.1)(vue@2.7.16):
dependencies:
- chart.js: 4.5.0
+ chart.js: 4.5.1
vue: 2.7.16
vue-class-component@7.2.6(vue@2.7.16):
@@ -20185,7 +20303,7 @@ snapshots:
espree: 9.6.1
esquery: 1.5.0
lodash: 4.17.21
- semver: 7.5.4
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
@@ -20198,7 +20316,7 @@ snapshots:
espree: 9.6.1
esquery: 1.6.0
lodash: 4.17.21
- semver: 7.7.2
+ semver: 7.7.3
transitivePeerDependencies:
- supports-color
@@ -20227,10 +20345,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vue-loader@15.11.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(cache-loader@4.1.0(webpack@4.47.0))(css-loader@5.2.7(webpack@5.102.0))(ejs@3.1.10)(handlebars@4.7.8)(lodash@4.17.21)(prettier@3.6.2)(vue-template-compiler@2.7.16)(webpack@4.47.0):
+ vue-loader@15.11.1(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(cache-loader@4.1.0(webpack@4.47.0))(css-loader@5.2.7(webpack@5.104.1))(ejs@3.1.10)(handlebars@4.7.8)(lodash@4.17.21)(prettier@3.8.1)(vue-template-compiler@2.7.16)(webpack@4.47.0):
dependencies:
'@vue/component-compiler-utils': 3.3.0(babel-core@7.0.0-bridge.0(@babel/core@7.28.4))(ejs@3.1.10)(handlebars@4.7.8)(lodash@4.17.21)
- css-loader: 5.2.7(webpack@5.102.0)
+ css-loader: 5.2.7(webpack@5.104.1)
hash-sum: 1.0.2
loader-utils: 1.4.2
vue-hot-reload-api: 2.3.4
@@ -20238,7 +20356,7 @@ snapshots:
webpack: 4.47.0
optionalDependencies:
cache-loader: 4.1.0(webpack@4.47.0)
- prettier: 3.6.2
+ prettier: 3.8.1
vue-template-compiler: 2.7.16
transitivePeerDependencies:
- arc-templates
@@ -20338,16 +20456,16 @@ snapshots:
'@vue/compiler-sfc': 2.7.16
csstype: 3.1.2
- vuetify-loader@1.9.2(vue@2.7.16)(vuetify@2.7.2(vue@2.7.16))(webpack@5.102.0):
+ vuetify-loader@1.9.2(vue@2.7.16)(vuetify@2.7.2(vue@2.7.16))(webpack@5.104.1):
dependencies:
acorn: 8.15.0
acorn-walk: 8.2.0
decache: 4.6.2
- file-loader: 6.2.0(webpack@5.102.0)
+ file-loader: 6.2.0(webpack@5.104.1)
loader-utils: 2.0.4
vue: 2.7.16
vuetify: 2.7.2(vue@2.7.16)
- webpack: 5.102.0
+ webpack: 5.104.1
vuetify@2.7.2(vue@2.7.16):
dependencies:
@@ -20382,7 +20500,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- watchpack@2.4.4:
+ watchpack@2.5.0:
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
@@ -20402,7 +20520,7 @@ snapshots:
gzip-size: 6.0.0
html-escaper: 2.0.2
opener: 1.5.2
- picocolors: 1.0.0
+ picocolors: 1.1.1
sirv: 2.0.3
ws: 7.5.10
transitivePeerDependencies:
@@ -20415,7 +20533,7 @@ snapshots:
memfs: 3.5.3
mime-types: 2.1.35
range-parser: 1.2.1
- schema-utils: 4.3.2
+ schema-utils: 4.3.3
webpack: 4.47.0
webpack-hot-middleware@2.26.1:
@@ -20465,7 +20583,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- webpack@5.102.0:
+ webpack@5.104.1:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -20475,22 +20593,22 @@ snapshots:
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4(acorn@8.15.0)
- browserslist: 4.26.2
+ browserslist: 4.28.1
chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.3
- es-module-lexer: 1.7.0
+ enhanced-resolve: 5.18.4
+ es-module-lexer: 2.0.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
- loader-runner: 4.3.0
+ loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
- schema-utils: 4.3.2
- tapable: 2.2.3
- terser-webpack-plugin: 5.3.14(webpack@5.102.0)
- watchpack: 2.4.4
+ schema-utils: 4.3.3
+ tapable: 2.3.0
+ terser-webpack-plugin: 5.3.16(webpack@5.104.1)
+ watchpack: 2.5.0
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
@@ -20625,7 +20743,7 @@ snapshots:
ws@7.5.10: {}
- ws@8.18.3: {}
+ ws@8.20.0: {}
xdg-basedir@4.0.0:
optional: true
@@ -20703,5 +20821,3 @@ snapshots:
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
-
- yocto-queue@1.2.1: {}
diff --git a/web/static/templates/httpsms-bulk.csv b/web/static/templates/httpsms-bulk.csv
index 38891f63..66411bc7 100644
--- a/web/static/templates/httpsms-bulk.csv
+++ b/web/static/templates/httpsms-bulk.csv
@@ -1,3 +1,3 @@
-FromPhoneNumber,ToPhoneNumber,Content
-+18005550199,+18005550100,This is a sample text message1
-+18005550199,+18005550100,This is a sample text message2
+FromPhoneNumber,ToPhoneNumber,Content,SendTime(optional)
+18005550199,18005550100,This is a sample text message1,
+18005550199,18005550100,This is a sample text message2,2023-11-11T02:10:01
diff --git a/web/static/templates/httpsms-bulk.xlsx b/web/static/templates/httpsms-bulk.xlsx
index bb713087..ca23f441 100644
Binary files a/web/static/templates/httpsms-bulk.xlsx and b/web/static/templates/httpsms-bulk.xlsx differ
diff --git a/web/store/index.ts b/web/store/index.ts
index 92c15c5d..4e9e9482 100644
--- a/web/store/index.ts
+++ b/web/store/index.ts
@@ -9,17 +9,22 @@ import { BillingUsage } from '~/models/billing'
import {
EntitiesDiscord,
EntitiesMessage,
+ EntitiesMessageSendSchedule,
EntitiesPhone,
EntitiesPhoneAPIKey,
EntitiesUser,
EntitiesWebhook,
RequestsDiscordStore,
RequestsDiscordUpdate,
+ RequestsMessageSendScheduleStore,
RequestsUserNotificationUpdate,
+ RequestsUserPaymentInvoice,
RequestsWebhookStore,
RequestsWebhookUpdate,
ResponsesDiscordResponse,
ResponsesDiscordsResponse,
+ ResponsesMessageSendScheduleResponse,
+ ResponsesMessageSendSchedulesResponse,
ResponsesMessagesResponse,
ResponsesNoContent,
ResponsesOkString,
@@ -27,6 +32,7 @@ import {
ResponsesPhoneAPIKeysResponse,
ResponsesUnprocessableEntity,
ResponsesUserResponse,
+ ResponsesUserSubscriptionPaymentsResponse,
ResponsesWebhookResponse,
ResponsesWebhooksResponse,
} from '~/models/api'
@@ -288,6 +294,7 @@ export type SendMessageRequest = {
to: string
content: string
sim: SIM
+ request_id?: string
}
export const actions = {
@@ -364,8 +371,8 @@ export const actions = {
context: ActionContext,
phone: EntitiesPhone,
) {
- await axios
- .put(`/v1/phones`, {
+ try {
+ const response = await axios.put(`/v1/phones`, {
fcm_token: phone.fcm_token,
sim: phone.sim,
phone_number: phone.phone_number,
@@ -375,18 +382,35 @@ export const actions = {
missed_call_auto_reply: phone.missed_call_auto_reply,
max_send_attempts: parseInt(phone.max_send_attempts.toString()),
messages_per_minute: parseInt(phone.messages_per_minute.toString()),
+ message_send_schedule_id: phone.message_send_schedule_id ?? null,
})
- .catch((error: AxiosError) => {
- context.dispatch('handleAxiosError', error)
- })
- .then((response: any) => {
- context.dispatch('addNotification', {
- message: response.data.message,
- type: 'success',
- })
+
+ context.dispatch('addNotification', {
+ message: response.data.message,
+ type: 'success',
})
- await context.dispatch('loadPhones', true)
+ await context.dispatch('loadPhones', true)
+ } catch (error) {
+ await context.dispatch('handleAxiosError', error as AxiosError)
+ }
+ },
+
+ fetchBulkMessageOrders(context: ActionContext) {
+ return new Promise((resolve, reject) => {
+ axios
+ .get<{ data: any[] }>(`/v1/bulk-messages`)
+ .then((response) => {
+ resolve(response.data.data ?? [])
+ })
+ .catch(async (error: AxiosError) => {
+ await context.dispatch('addNotification', {
+ message: 'Error while fetching bulk messages history',
+ type: 'error',
+ })
+ reject(error)
+ })
+ })
},
sendBulkMessages(context: ActionContext, document: File) {
@@ -533,6 +557,109 @@ export const actions = {
})
},
+ indexSubscriptionPayments(context: ActionContext) {
+ return new Promise(
+ (resolve, reject) => {
+ axios
+ .get(
+ `/v1/users/subscription/payments`,
+ {
+ params: {
+ limit: 100,
+ },
+ },
+ )
+ .then(
+ (
+ response: AxiosResponse,
+ ) => {
+ resolve(response.data)
+ },
+ )
+ .catch(async (error: AxiosError) => {
+ await Promise.all([
+ context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while fetching subscription payments.',
+ type: 'error',
+ }),
+ ])
+ reject(getErrorMessages(error))
+ })
+ },
+ )
+ },
+
+ generateSubscriptionPaymentInvoice(
+ context: ActionContext,
+ payload: {
+ subscriptionInvoiceId: string
+ request: RequestsUserPaymentInvoice
+ },
+ ) {
+ return new Promise((resolve, reject) => {
+ axios
+ .post(
+ `/v1/users/subscription/invoices/${payload.subscriptionInvoiceId}`,
+ payload.request,
+ {
+ responseType: 'blob',
+ },
+ )
+ .then(async (response: AxiosResponse) => {
+ // Create a Blob from the response data
+ const pdfBlob = new Blob([response.data], {
+ type: response.headers['content-type'],
+ })
+
+ // Create a temporary URL for the Blob
+ const url = window.URL.createObjectURL(pdfBlob)
+
+ // Create a temporary element to trigger the download
+ const tempLink = document.createElement('a')
+ tempLink.href = url
+ tempLink.setAttribute(
+ 'download',
+ response.headers['content-disposition']
+ ?.split('filename=')[1]
+ .replaceAll('"', '') || 'Invoice.pdf',
+ ) // Set the desired filename for the downloaded file
+
+ // Append the element to the body and click it to trigger the download
+ document.body.appendChild(tempLink)
+ tempLink.click()
+
+ // Clean up the temporary elements and URL
+ document.body.removeChild(tempLink)
+ window.URL.revokeObjectURL(url)
+
+ await context.dispatch('addNotification', {
+ message:
+ response.data.message ??
+ 'Your invoice has been generated successfully',
+ type: 'success',
+ })
+ resolve()
+ })
+ .catch(async (error: AxiosError) => {
+ const text = await (error.response as any).data.text()
+ if (error.response) {
+ error.response.data = JSON.parse(text)
+ }
+ await Promise.all([
+ context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while generating your invoice',
+ type: 'error',
+ }),
+ ])
+ reject(getErrorMessages(error))
+ })
+ })
+ },
+
async handleAxiosError(
context: ActionContext,
error: AxiosError,
@@ -999,6 +1126,104 @@ export const actions = {
})
},
+ getSendSchedules(context: ActionContext) {
+ return new Promise>(
+ (resolve, reject) => {
+ axios
+ .get(`/v1/send-schedules`)
+ .then(
+ (
+ response: AxiosResponse,
+ ) => {
+ resolve(response.data.data)
+ },
+ )
+ .catch(async (error: AxiosError) => {
+ await context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while fetching send schedules',
+ type: 'error',
+ })
+ reject(getErrorMessages(error))
+ })
+ },
+ )
+ },
+
+ createSendSchedule(
+ context: ActionContext,
+ payload: RequestsMessageSendScheduleStore,
+ ) {
+ return new Promise((resolve, reject) => {
+ axios
+ .post(
+ `/v1/send-schedules`,
+ payload,
+ )
+ .then(
+ (response: AxiosResponse) => {
+ resolve(response.data.data)
+ },
+ )
+ .catch(async (error: AxiosError) => {
+ await context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while creating send schedule',
+ type: 'error',
+ })
+ reject(getErrorMessages(error))
+ })
+ })
+ },
+
+ updateSendSchedule(
+ context: ActionContext,
+ payload: RequestsMessageSendScheduleStore & { id: string },
+ ) {
+ return new Promise((resolve, reject) => {
+ axios
+ .put(
+ `/v1/send-schedules/${payload.id}`,
+ payload,
+ )
+ .then(
+ (response: AxiosResponse) => {
+ resolve(response.data.data)
+ },
+ )
+ .catch(async (error: AxiosError) => {
+ await context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while updating send schedule',
+ type: 'error',
+ })
+ reject(getErrorMessages(error))
+ })
+ })
+ },
+
+ deleteSendSchedule(context: ActionContext, payload: string) {
+ return new Promise((resolve, reject) => {
+ axios
+ .delete(`/v1/send-schedules/${payload}`)
+ .then(() => {
+ resolve()
+ })
+ .catch(async (error: AxiosError) => {
+ await context.dispatch('addNotification', {
+ message:
+ (error.response?.data as any)?.message ??
+ 'Error while deleting send schedule',
+ type: 'error',
+ })
+ reject(getErrorMessages(error))
+ })
+ })
+ },
+
createWebhook(
context: ActionContext,
payload: RequestsWebhookStore,