diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml
index 483f3dbeee..948fa6c0c1 100644
--- a/.github/workflows/ai-moderator.yml
+++ b/.github/workflows/ai-moderator.yml
@@ -25,6 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: AI Moderator
- uses: github/ai-moderator@v1
+ uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml
index e0eb0de98d..0151c2f9c1 100644
--- a/.github/workflows/auto-label-issue.yml
+++ b/.github/workflows/auto-label-issue.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
- uses: github/issue-labeler@v3.4
+ uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
with:
configuration-path: .github/labeler.yml
enable-versioned-regex: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3736c62f3e..08804bd723 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
actions: read
security-events: write
contents: read
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
security:
name: Checks / Image
@@ -43,13 +43,13 @@ jobs:
security-events: write
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
@@ -58,7 +58,7 @@ jobs:
target: production
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'sarif'
@@ -66,7 +66,7 @@ jobs:
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
@@ -76,14 +76,14 @@ jobs:
skip-setup-trivy: true
- name: Upload image scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
- name: Upload source code scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
@@ -94,10 +94,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
@@ -127,7 +127,7 @@ jobs:
if: github.event_name == 'pull_request'
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -144,10 +144,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -157,7 +157,7 @@ jobs:
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Cache PHPStan result cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .phpstan-cache
key: phpstan-${{ github.sha }}
@@ -172,10 +172,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
extensions: swoole
@@ -193,10 +193,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
@@ -212,7 +212,7 @@ jobs:
steps:
- name: Generate matrix
id: generate
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
@@ -253,28 +253,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push Appwrite
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: true
@@ -297,16 +297,16 @@ jobs:
packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -327,7 +327,7 @@ jobs:
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -350,16 +350,16 @@ jobs:
packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -385,7 +385,7 @@ jobs:
done
- name: Run General Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -406,7 +406,7 @@ jobs:
e2e_service:
name: Tests / E2E / ${{ matrix.database }} (${{ matrix.mode }}) / ${{ matrix.service }}
- runs-on: ${{ matrix.runner || 'ubuntu-latest' }}
+ runs-on: ${{ matrix.runner || format('runs-on={0}/runner=4cpu-linux-x64/volume=120g/spot=false', github.run_id) }}
needs: [build, matrix]
permissions:
contents: read
@@ -427,6 +427,7 @@ jobs:
FunctionsSchedule,
GraphQL,
Health,
+ Advisor,
Locale,
Projects,
Realtime,
@@ -446,26 +447,18 @@ jobs:
]
include:
- service: Databases
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
paratest_processes: 3
timeout_minutes: 30
- - service: Sites
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Functions
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Avatars
- runner: blacksmith-4vcpu-ubuntu-2404
- - service: Realtime
- runner: blacksmith-4vcpu-ubuntu-2404
- service: TablesDB
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=8cpu-linux-x64/volume=120g/spot=false
paratest_processes: 3
timeout_minutes: 30
- service: Migrations
paratest_processes: 1
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set environment
run: |
@@ -489,13 +482,13 @@ jobs:
fi
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -526,7 +519,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -574,18 +567,18 @@ jobs:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -608,7 +601,7 @@ jobs:
docker compose up -d --quiet-pull --wait
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -641,16 +634,16 @@ jobs:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -680,7 +673,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -712,18 +705,18 @@ jobs:
packages: read
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -736,7 +729,7 @@ jobs:
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after
- name: Setup k6
- uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
+ uses: grafana/setup-k6-action@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1
with:
k6-version: ${{ env.K6_VERSION }}
@@ -775,7 +768,7 @@ jobs:
- name: Benchmark before
if: steps.benchmark_before_start.outcome == 'success'
continue-on-error: true
- uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
@@ -827,7 +820,7 @@ jobs:
- name: Benchmark after
id: benchmark_after
continue-on-error: true
- uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
@@ -847,7 +840,7 @@ jobs:
- name: Comment on PR
if: always()
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
@@ -857,7 +850,7 @@ jobs:
await comment({ github, context, core });
- name: Save results
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: benchmark-results
diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml
index 4b6b13d35d..e4f28816be 100644
--- a/.github/workflows/cleanup-cache.yml
+++ b/.github/workflows/cleanup-cache.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cleanup
run: |
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7edfde0aae..cb9b09b496 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -47,14 +47,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# âšī¸ Command-line programs to run using the OS shell.
# đ https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c4289678bb..0a49f658ac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Build the Docker image
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'appwrite_image:latest'
format: 'sarif'
@@ -24,7 +24,7 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
@@ -35,16 +35,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Trivy vulnerability scanner on filesystem
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 692861d44d..68ab657213 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,33 +12,33 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/cloud
tags: |
type=ref,event=tag
- name: Build & Publish to DockerHub
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84fc4c9fba..ed4e46d811 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -20,20 +20,20 @@ jobs:
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/appwrite
tags: |
@@ -42,7 +42,7 @@ jobs:
type=semver,pattern={{major}}
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml
index f81346a7d1..dacc37a64a 100644
--- a/.github/workflows/sdk-preview.yml
+++ b/.github/workflows/sdk-preview.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set SDK type
id: set-sdk
@@ -49,7 +49,7 @@ jobs:
docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no
sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 6f377354d5..85c76bacd3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 6e4a8ba73b..73b767aafe 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php
index 748211f222..7496b7a9a7 100644
--- a/app/config/collections/platform.php
+++ b/app/config/collections/platform.php
@@ -1956,6 +1956,440 @@ $platformCollections = [
'attributes' => [],
'indexes' => []
],
+
+ 'reports' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('reports'),
+ 'name' => 'Reports',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('appId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Resource type the report is about. Plural noun, e.g. databases, sites, urls.
+ '$id' => ID::custom('targetType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Free-form target identifier (URL for lighthouse, resource ID for db).
+ // Indexed by `_key_project_target` with an explicit prefix length.
+ '$id' => ID::custom('target'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ // Category strings, e.g. 'performance', 'accessibility'. Native array
+ // column â we never query on individual entries (MySQL JSON-array
+ // indexes are weak), this is read+rewrite only.
+ '$id' => ID::custom('categories'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => true,
+ 'filters' => [],
+ ],
+ [
+ // Virtual attribute â insights live in the `insights` collection
+ // back-referenced by `reportInternalId`. The subQuery filter joins
+ // them at read time.
+ '$id' => ID::custom('insights'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['subQueryReportInsights'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_app_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_target'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'appInternalId', 'targetType', 'target'],
+ 'lengths' => [null, null, null, 700],
+ 'orders' => [],
+ ],
+ ],
+ ],
+
+ 'insights' => [
+ '$collection' => ID::custom(Database::METADATA),
+ '$id' => ID::custom('insights'),
+ 'name' => 'Insights',
+ 'attributes' => [
+ [
+ '$id' => ID::custom('projectInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('projectId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('reportId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('type'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('severity'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('status'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 16,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => 'active',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('resourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceType'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 64,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceId'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('parentResourceInternalId'),
+ 'type' => Database::VAR_ID,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('title'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => 256,
+ 'signed' => true,
+ 'required' => true,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('summary'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ [
+ '$id' => ID::custom('ctas'),
+ 'type' => Database::VAR_TEXT,
+ 'format' => '',
+ 'size' => 65535,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['json'],
+ ],
+ [
+ '$id' => ID::custom('analyzedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedAt'),
+ 'type' => Database::VAR_DATETIME,
+ 'format' => '',
+ 'size' => 0,
+ 'signed' => false,
+ 'required' => false,
+ 'default' => null,
+ 'array' => false,
+ 'filters' => ['datetime'],
+ ],
+ [
+ '$id' => ID::custom('dismissedBy'),
+ 'type' => Database::VAR_STRING,
+ 'format' => '',
+ 'size' => Database::LENGTH_KEY,
+ 'signed' => true,
+ 'required' => false,
+ 'default' => '',
+ 'array' => false,
+ 'filters' => [],
+ ],
+ ],
+ 'indexes' => [
+ [
+ '$id' => ID::custom('_key_project_report'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'reportInternalId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'resourceType', 'resourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_parent_resource'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_type'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'type'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_severity'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'severity'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_status'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'status'],
+ 'lengths' => [],
+ 'orders' => [],
+ ],
+ [
+ '$id' => ID::custom('_key_project_dismissedAt'),
+ 'type' => Database::INDEX_KEY,
+ 'attributes' => ['projectInternalId', 'dismissedAt'],
+ 'lengths' => [],
+ 'orders' => [Database::ORDER_ASC, Database::ORDER_DESC],
+ ],
+ ],
+ ],
+
];
// Organization API keys subquery
diff --git a/app/config/errors.php b/app/config/errors.php
index e763c82cf2..67ddc68312 100644
--- a/app/config/errors.php
+++ b/app/config/errors.php
@@ -1253,6 +1253,26 @@ return [
'description' => 'The specified database type is not supported for CSV import or export operations.',
'code' => 400,
],
+ Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED,
+ 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND,
+ 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.',
+ 'code' => 404,
+ ],
+ Exception::MIGRATION_SOURCE_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID,
+ 'description' => 'The migration source type is invalid. Use one of the supported source types.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_DESTINATION_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID,
+ 'description' => 'The migration destination type is invalid. Use one of the supported destination types.',
+ 'code' => 400,
+ ],
/** Realtime */
Exception::REALTIME_MESSAGE_FORMAT_INVALID => [
@@ -1440,4 +1460,28 @@ return [
'description' => 'The maximum number of mock phones for this project has been reached.',
'code' => 400,
],
+
+ /** Advisor */
+ Exception::INSIGHT_NOT_FOUND => [
+ 'name' => Exception::INSIGHT_NOT_FOUND,
+ 'description' => 'Insight with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::INSIGHT_ALREADY_EXISTS => [
+ 'name' => Exception::INSIGHT_ALREADY_EXISTS,
+ 'description' => 'Insight with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
+
+ /** Reports */
+ Exception::REPORT_NOT_FOUND => [
+ 'name' => Exception::REPORT_NOT_FOUND,
+ 'description' => 'Report with the requested ID could not be found.',
+ 'code' => 404,
+ ],
+ Exception::REPORT_ALREADY_EXISTS => [
+ 'name' => Exception::REPORT_ALREADY_EXISTS,
+ 'description' => 'Report with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.',
+ 'code' => 409,
+ ],
];
diff --git a/app/config/events.php b/app/config/events.php
index 11dc2e0e4a..2825562ab7 100644
--- a/app/config/events.php
+++ b/app/config/events.php
@@ -426,5 +426,33 @@ return [
'update' => [
'$description' => 'This event triggers when a proxy rule is updated.',
]
- ]
+ ],
+ 'reports' => [
+ '$model' => Response::MODEL_REPORT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any report event.',
+ 'create' => [
+ '$description' => 'This event triggers when a report is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when a report is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when a report is deleted.',
+ ],
+ 'insights' => [
+ '$model' => Response::MODEL_INSIGHT,
+ '$resource' => true,
+ '$description' => 'This event triggers on any insight event.',
+ 'create' => [
+ '$description' => 'This event triggers when an insight is created.',
+ ],
+ 'update' => [
+ '$description' => 'This event triggers when an insight is updated.',
+ ],
+ 'delete' => [
+ '$description' => 'This event triggers when an insight is deleted.',
+ ],
+ ],
+ ],
];
diff --git a/app/config/roles.php b/app/config/roles.php
index 8d99ede520..abb8d4481f 100644
--- a/app/config/roles.php
+++ b/app/config/roles.php
@@ -107,6 +107,10 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
];
return [
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index a89470c9cf..b27f328308 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -361,6 +361,24 @@ return [
'description' => 'Access to create, update, and delete resources under VCS service.',
'category' => 'Other',
],
+
+ // Advisor
+ 'insights.read' => [
+ 'description' => 'Access to read insights under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'insights.write' => [
+ 'description' => 'Reserved for Advisor insight ingestion outside CE.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.read' => [
+ 'description' => 'Access to read reports under Advisor service.',
+ 'category' => 'Advisor',
+ ],
+ 'reports.write' => [
+ 'description' => 'Access to delete reports under Advisor service.',
+ 'category' => 'Advisor',
+ ],
'presences.read' => [
'description' => 'Access to read your project\'s presences',
],
diff --git a/app/config/sdks.php b/app/config/sdks.php
index e89265b05e..e29b28690f 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -320,6 +320,26 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.1',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
diff --git a/app/config/services.php b/app/config/services.php
index cf2714f8c5..f829937623 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -308,5 +308,19 @@ return [
'optional' => true,
'icon' => '/images/services/messaging.png',
'platforms' => ['client', 'server', 'console'],
- ]
+ ],
+ 'advisor' => [
+ 'key' => 'advisor',
+ 'name' => 'Advisor',
+ 'subtitle' => 'The Advisor service surfaces actionable reports about your project resources, with CTA descriptors for one-click remediation in the console.',
+ 'description' => '/docs/services/advisor.md',
+ 'controller' => '', // Uses modules
+ 'sdk' => true,
+ 'docs' => true,
+ 'docsUrl' => 'https://appwrite.io/docs/server/advisor',
+ 'tests' => true,
+ 'optional' => true,
+ 'icon' => '/images/services/insights.png',
+ 'platforms' => ['server', 'console'],
+ ],
];
diff --git a/app/config/variables.php b/app/config/variables.php
index c834656ff4..90df9b4518 100644
--- a/app/config/variables.php
+++ b/app/config/variables.php
@@ -1336,6 +1336,15 @@ return [
'category' => 'Migrations',
'description' => '',
'variables' => [
+ [
+ 'name' => '_APP_MIGRATION_HOST',
+ 'description' => 'Internal hostname the migrations worker uses to reach this instance\'s API (for migrations and CSV/JSON imports & exports). Defaults to \'appwrite\', the API service name in the standard Docker Compose setup. Only change this for non-standard deployments.',
+ 'introduction' => '1.9.0',
+ 'default' => 'appwrite',
+ 'required' => false,
+ 'question' => '',
+ 'filter' => ''
+ ],
[
'name' => '_APP_MIGRATIONS_FIREBASE_CLIENT_ID',
'description' => 'Google OAuth client ID. You can find it in your GCP application settings.',
diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php
index 65fd0bfb83..e01c27e45c 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -13,8 +13,10 @@ use Appwrite\Bus\Events\SessionCreated;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Hooks\Hooks;
use Appwrite\Network\Validator\Redirect;
@@ -332,15 +334,15 @@ Http::post('/v1/account')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -1676,15 +1678,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1817,15 +1819,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -2113,12 +2115,12 @@ Http::post('/v1/account/tokens/magic-url')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('plan')
->inject('proofForPassword')
->inject('platform')
->inject('authorization')
- ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
+ ->action(function (string $userId, string $email, string $url, bool $phrase, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, array $platform, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2175,15 +2177,15 @@ Http::post('/v1/account/tokens/magic-url')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2304,6 +2306,7 @@ Http::post('/v1/account/tokens/magic-url')
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2321,13 +2324,6 @@ Http::post('/v1/account/tokens/magic-url')
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -2348,11 +2344,17 @@ Http::post('/v1/account/tokens/magic-url')
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2374,18 +2376,17 @@ Http::post('/v1/account/tokens/magic-url')
'team' => '',
];
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2436,12 +2437,12 @@ Http::post('/v1/account/tokens/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('plan')
->inject('proofForPassword')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $email, bool $phrase, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, array $plan, ProofsPassword $proofForPassword, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
}
@@ -2496,15 +2497,15 @@ Http::post('/v1/account/tokens/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2633,6 +2634,7 @@ Http::post('/v1/account/tokens/email')
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -2650,13 +2652,6 @@ Http::post('/v1/account/tokens/email')
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -2677,11 +2672,17 @@ Http::post('/v1/account/tokens/email')
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$projectName = $project->getAttribute('name');
@@ -2717,20 +2718,18 @@ Http::post('/v1/account/tokens/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($email);
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$token->setAttribute('secret', $tokenSecret);
@@ -2880,7 +2879,7 @@ Http::post('/v1/account/tokens/phone')
->inject('platform')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('locale')
->inject('timelimit')
->inject('usage')
@@ -2888,7 +2887,7 @@ Http::post('/v1/account/tokens/phone')
->inject('store')
->inject('proofForCode')
->inject('authorization')
- ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (string $userId, string $phone, Request $request, Response $response, User $user, Document $project, array $platform, Database $dbForProject, Event $queueForEvents, MessagingPublisher $publisherForMessaging, Locale $locale, callable $timelimit, Context $usage, array $plan, Store $store, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -3021,11 +3020,13 @@ Http::post('/v1/account/tokens/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
@@ -3417,15 +3418,15 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -3681,11 +3682,11 @@ Http::post('/v1/account/recovery')
->inject('project')
->inject('platform')
->inject('locale')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Mail $queueForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $email, string $url, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, MailPublisher $publisherForMails, Event $queueForEvents, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -3768,6 +3769,7 @@ Http::post('/v1/account/recovery')
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -3785,13 +3787,6 @@ Http::post('/v1/account/recovery')
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -3812,11 +3807,17 @@ Http::post('/v1/account/recovery')
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -3829,19 +3830,18 @@ Http::post('/v1/account/recovery')
'team' => ''
];
- $queueForMails
- ->setRecipient($profile->getAttribute('email', ''))
- ->setName($profile->getAttribute('name', ''))
- ->setBody($body)
- ->appendVariables($emailVariables)
- ->setSubject($subject)
- ->setPreview($preview);
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $profile->getAttribute('email', ''),
+ name: $profile->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$recovery->setAttribute('secret', $secret);
@@ -4009,10 +4009,10 @@ Http::post('/v1/account/verifications/email')
->inject('dbForProject')
->inject('locale')
->inject('queueForEvents')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('proofForToken')
->inject('authorization')
- ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, Mail $queueForMails, ProofsToken $proofForToken, Authorization $authorization) {
+ ->action(function (string $url, Request $request, Response $response, Document $project, array $platform, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, MailPublisher $publisherForMails, ProofsToken $proofForToken, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMTP_HOST'))) {
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
@@ -4099,6 +4099,7 @@ Http::post('/v1/account/verifications/email')
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -4116,13 +4117,6 @@ Http::post('/v1/account/verifications/email')
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -4143,11 +4137,17 @@ Http::post('/v1/account/verifications/email')
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -4174,20 +4174,19 @@ Http::post('/v1/account/verifications/email')
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'))
- ->setName($user->getAttribute('name') ?? '');
-
- if ($project->getId() === 'console') {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name') ?? '',
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $project->getId() === 'console' ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
$verification->setAttribute('secret', $verificationSecret);
@@ -4321,7 +4320,7 @@ Http::post('/v1/account/verifications/phone')
->inject('user')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('project')
->inject('locale')
->inject('timelimit')
@@ -4329,7 +4328,7 @@ Http::post('/v1/account/verifications/phone')
->inject('plan')
->inject('proofForCode')
->inject('authorization')
- ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Messaging $queueForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
+ ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Event $queueForEvents, MessagingPublisher $publisherForMessaging, Document $project, Locale $locale, callable $timelimit, Context $usage, array $plan, ProofsCode $proofForCode, Authorization $authorization) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
}
@@ -4398,11 +4397,13 @@ Http::post('/v1/account/verifications/phone')
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$user->getAttribute('phone')])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$user->getAttribute('phone')],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php
index 58c6a2c29e..f59f606174 100644
--- a/app/controllers/api/messaging.php
+++ b/app/controllers/api/messaging.php
@@ -5,7 +5,8 @@ use Appwrite\Auth\Validator\Phone;
use Appwrite\Detector\Detector;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Messaging\Status as MessageStatus;
use Appwrite\Permission;
@@ -3187,9 +3188,9 @@ Http::post('/v1/messaging/messages/email')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $subject, string $content, ?array $topics, ?array $users, ?array $targets, ?array $cc, ?array $bcc, ?array $attachments, bool $draft, bool $html, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3274,9 +3275,11 @@ Http::post('/v1/messaging/messages/email')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3362,9 +3365,9 @@ Http::post('/v1/messaging/messages/sms')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, string $content, ?array $topics, ?array $users, ?array $targets, bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3418,9 +3421,11 @@ Http::post('/v1/messaging/messages/sms')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3498,10 +3503,10 @@ Http::post('/v1/messaging/messages/push')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, string $title, string $body, ?array $topics, ?array $users, ?array $targets, ?array $data, string $action, string $image, string $icon, string $sound, string $color, string $tag, int $badge, bool $draft, ?string $scheduledAt, bool $contentAvailable, bool $critical, string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$messageId = $messageId == 'unique()'
? ID::unique()
: $messageId;
@@ -3638,9 +3643,11 @@ Http::post('/v1/messaging/messages/push')
switch ($status) {
case MessageStatus::PROCESSING:
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
break;
case MessageStatus::SCHEDULED:
$schedule = $dbForPlatform->createDocument('schedules', new Document([
@@ -3983,9 +3990,9 @@ Http::patch('/v1/messaging/messages/email/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $subject, ?string $content, ?bool $draft, ?bool $html, ?array $cc, ?array $bcc, ?string $scheduledAt, ?array $attachments, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4141,9 +4148,11 @@ Http::patch('/v1/messaging/messages/email/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4205,9 +4214,9 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $content, ?bool $draft, ?string $scheduledAt, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4323,9 +4332,11 @@ Http::patch('/v1/messaging/messages/sms/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
@@ -4379,10 +4390,10 @@ Http::patch('/v1/messaging/messages/push/:messageId')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('project')
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->inject('platform')
- ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, Messaging $queueForMessaging, Response $response, array $platform) {
+ ->action(function (string $messageId, ?array $topics, ?array $users, ?array $targets, ?string $title, ?string $body, ?array $data, ?string $action, ?string $image, ?string $icon, ?string $sound, ?string $color, ?string $tag, ?int $badge, ?bool $draft, ?string $scheduledAt, ?bool $contentAvailable, ?bool $critical, ?string $priority, Event $queueForEvents, Database $dbForProject, Database $dbForPlatform, Document $project, MessagingPublisher $publisherForMessaging, Response $response, array $platform) {
$message = $dbForProject->getDocument('messages', $messageId);
if ($message->isEmpty()) {
@@ -4584,9 +4595,11 @@ Http::patch('/v1/messaging/messages/push/:messageId')
$message = $dbForProject->updateDocument('messages', $message->getId(), $message);
if ($status === MessageStatus::PROCESSING) {
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($message->getId());
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $project,
+ messageId: $message->getId(),
+ ));
}
$queueForEvents
diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php
index 494aa11150..8ab30fac99 100644
--- a/app/controllers/api/projects.php
+++ b/app/controllers/api/projects.php
@@ -2,9 +2,6 @@
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Extend\Exception;
-use Appwrite\SDK\AuthType;
-use Appwrite\SDK\Method;
-use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
@@ -27,37 +24,6 @@ Http::init()
}
});
-Http::get('/v1/projects/:projectId')
- ->desc('Get project')
- ->groups(['api', 'projects'])
- ->label('scope', 'projects.read')
- ->label('sdk', new Method(
- namespace: 'projects',
- group: 'projects',
- name: 'get',
- description: '/docs/references/projects/get.md',
- auth: [AuthType::ADMIN],
- responses: [
- new SDKResponse(
- code: Response::STATUS_CODE_OK,
- model: Response::MODEL_PROJECT,
- )
- ]
- ))
- ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
- ->inject('response')
- ->inject('dbForPlatform')
- ->action(function (string $projectId, Response $response, Database $dbForPlatform) {
-
- $project = $dbForPlatform->getDocument('projects', $projectId);
-
- if ($project->isEmpty()) {
- throw new Exception(Exception::PROJECT_NOT_FOUND);
- }
-
- $response->dynamic($project, Response::MODEL_PROJECT);
- });
-
// Backwards compatibility
Http::patch('/v1/projects/:projectId/oauth2')
->desc('Update project OAuth2')
diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php
index abcecac396..3f52069609 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -131,15 +131,15 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -1563,15 +1563,15 @@ Http::patch('/v1/users/:userId/email')
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
diff --git a/app/controllers/general.php b/app/controllers/general.php
index bc63d200d7..dbcfa7f754 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -29,6 +29,7 @@ use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Request\Filters\V24 as RequestV24;
use Appwrite\Utopia\Request\Filters\V25 as RequestV25;
+use Appwrite\Utopia\Request\Filters\V26 as RequestV26;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -40,6 +41,7 @@ use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\Response\Filters\V24 as ResponseV24;
use Appwrite\Utopia\Response\Filters\V25 as ResponseV25;
+use Appwrite\Utopia\Response\Filters\V26 as ResponseV26;
use Appwrite\Utopia\View;
use Executor\Exception\Timeout as ExecutorTimeout;
use Executor\Executor;
@@ -914,6 +916,9 @@ Http::init()
if (version_compare($requestFormat, '1.9.4', '<')) {
$request->addFilter(new RequestV25());
}
+ if (version_compare($requestFormat, '1.9.5', '<')) {
+ $request->addFilter(new RequestV26());
+ }
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -938,6 +943,9 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
+ if (version_compare($responseFormat, '1.9.5', '<')) {
+ $response->addFilter(new ResponseV26());
+ }
if (version_compare($responseFormat, '1.9.4', '<')) {
$response->addFilter(new ResponseV25());
}
@@ -1274,7 +1282,7 @@ Http::error()
if (!$publish && $project->getId() !== 'console') {
$errorUser = new DBUser();
try {
- $resolvedUser = $utopia->getResource('user');
+ $resolvedUser = $utopia->context()->get('user');
if ($resolvedUser instanceof DBUser) {
$errorUser = $resolvedUser;
}
@@ -1293,7 +1301,7 @@ Http::error()
if ($logger && $publish) {
try {
/** @var Utopia\Database\Document $user */
- $user = $utopia->getResource('user');
+ $user = $utopia->context()->get('user');
} catch (\Throwable) {
// All good, user is optional information for logger
}
@@ -1494,7 +1502,7 @@ Http::error()
// the cors resource (which depends on rule -> DB) would cascade.
// Uses override:true to avoid duplicate headers if init() already set them.
try {
- $cors = $utopia->getResource('cors');
+ $cors = $utopia->context()->get('cors');
foreach ($cors->headers($request->getOrigin()) as $name => $value) {
$response
->removeHeader($name)
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index c9e4f8b47d..8365274e98 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -3,16 +3,13 @@
use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Build;
use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
use Appwrite\Event\Message\Audit as AuditMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
-use Appwrite\Event\Messaging;
use Appwrite\Event\Publisher\Audit;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
@@ -20,6 +17,8 @@ use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\EventProcessor;
+use Appwrite\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\Method;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
@@ -485,14 +484,11 @@ Http::init()
->inject('project')
->inject('user')
->inject('queueForEvents')
- ->inject('queueForMessaging')
->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
->inject('usage')
->inject('queueForFunctions')
- ->inject('queueForMails')
->inject('dbForProject')
->inject('timelimit')
->inject('resourceToken')
@@ -503,7 +499,8 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
+ ->inject('cacheControlForStorage')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
$response->setUser($user);
$request->setUser($user);
@@ -616,15 +613,10 @@ Http::init()
/* Auto-set projects */
$queueForDeletes->setProject($project);
$queueForDatabase->setProject($project);
- $queueForMessaging->setProject($project);
$queueForFunctions->setProject($project);
- $queueForBuilds->setProject($project);
- $queueForMails->setProject($project);
/* Auto-set platforms */
$queueForFunctions->setPlatform($platform);
- $queueForBuilds->setPlatform($platform);
- $queueForMails->setPlatform($platform);
$useCache = $route->getLabel('cache', false);
$storageCacheOperationsCounter = $telemetry->createCounter('storage.cache.operations.load');
@@ -643,6 +635,7 @@ Http::init()
$data = $cache->load($key, $timestamp);
if (! empty($data) && ! $cacheLog->isEmpty()) {
+ $cacheControl = \sprintf('private, max-age=%d', $timestamp);
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
$type = $parts[0];
@@ -695,6 +688,21 @@ Http::init()
])));
}
}
+
+ if ($isImageTransformation) {
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_CACHE,
+ user: $user,
+ maxAge: $timestamp,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ cacheLog: $cacheLog,
+ route: $route,
+ ));
+ }
}
$accessedAt = $cacheLog->getAttribute('accessedAt', '');
@@ -707,7 +715,7 @@ Http::init()
}
$response
- ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
+ ->addHeader('Cache-Control', $cacheControl)
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
@@ -800,8 +808,6 @@ Http::shutdown()
->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
->inject('queueForFunctions')
->inject('queueForWebhooks')
->inject('queueForRealtime')
@@ -812,7 +818,7 @@ Http::shutdown()
->inject('bus')
->inject('apiKey')
->inject('mode')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -961,14 +967,6 @@ Http::shutdown()
$queueForDatabase->trigger();
}
- if (! empty($queueForBuilds->getType())) {
- $queueForBuilds->trigger();
- }
-
- if (! empty($queueForMessaging->getType())) {
- $queueForMessaging->trigger();
- }
-
// Cache label
$useCache = $route->getLabel('cache', false);
if ($useCache) {
diff --git a/app/http.php b/app/http.php
index b72f3b7f34..6dc415f000 100644
--- a/app/http.php
+++ b/app/http.php
@@ -3,7 +3,7 @@
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
-$registerRequestResources = require __DIR__ . '/init/resources/request.php';
+$setRequestContext = require __DIR__ . '/init/resources/request.php';
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
@@ -26,6 +26,7 @@ use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
+use Utopia\DI\Container;
use Utopia\Http\Adapter\Swoole\Server;
use Utopia\Http\Files;
use Utopia\Http\Http;
@@ -57,7 +58,7 @@ $container->set('pools', function ($register) {
$payloadSize = 12 * (1024 * 1024); // 12MB - adding slight buffer for headers and other data that might be sent with the payload - update later with valid testing
$totalWorkers = intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6));
-$swooleAdapter = new Server(
+$swoole = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
settings: [
@@ -69,10 +70,10 @@ $swooleAdapter = new Server(
Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
Constant::OPTION_TASK_WORKER_NUM => 1, // required for the task to fetch domains background
],
- container: $container,
+ resources: $container,
);
-$http = $swooleAdapter->getServer();
+$http = $swoole->getServer();
/**
* Assigns HTTP requests to worker threads by analyzing its payload/content.
@@ -190,13 +191,11 @@ $http->on(Constant::EVENT_AFTER_RELOAD, function ($server) {
Console::success('Reload completed...');
});
-$container->set('bus', function ($register) use ($swooleAdapter) {
- return $register->get('bus')->setResolver(fn (string $name) => $swooleAdapter->getContainer()->get($name));
-}, ['register']);
+$container->set('bus', fn ($register) => $register->get('bus')->setResolver(fn (string $name) => $swoole->context()->get($name)), ['register']);
include __DIR__ . '/controllers/general.php';
-function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
+function createDatabase(Container $resources, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void
{
$max = 15;
$sleep = 2;
@@ -205,7 +204,7 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
while (true) {
try {
$attempts++;
- $resource = $app->getResource($resourceKey);
+ $resource = $resources->get($resourceKey);
/* @var $database Database */
$database = is_callable($resource) ? $resource() : $resource;
break; // exit loop on success
@@ -288,23 +287,21 @@ function createDatabase(Http $app, string $resourceKey, string $dbName, array $c
Span::current()?->finish();
}
-$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $swooleAdapter) {
- $app = new Http($swooleAdapter, 'UTC');
-
+$http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorkers, $container) {
/** @var \Utopia\Pools\Group $pools */
- $pools = $app->getResource('pools');
+ $pools = $container->get('pools');
- go(function () use ($app, $pools) {
+ go(function () use ($container, $pools) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
// create logs database first, `getLogsDB` is a callable.
- createDatabase($app, 'getLogsDB', 'logs', $collections['logs'], $pools);
+ createDatabase($container, 'getLogsDB', 'logs', $collections['logs'], $pools);
// create appwrite database, `dbForPlatform` is a direct access call.
- createDatabase($app, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $app) {
- $authorization = $app->getResource('authorization');
+ createDatabase($container, 'dbForPlatform', 'appwrite', $collections['console'], $pools, function (Database $dbForPlatform) use ($collections, $container) {
+ $authorization = $container->get('authorization');
if ($dbForPlatform->getCollection(AuditAdapterSQL::COLLECTION)->isEmpty()) {
$adapter = new AdapterDatabase($dbForPlatform);
@@ -416,7 +413,7 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
$documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', ''));
$vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', ''));
- $cache = $app->getResource('cache');
+ $cache = $container->get('cache');
// All shared tables pools that need project metadata collections
$allSharedTables = \array_values(\array_unique(\array_filter([
@@ -502,7 +499,7 @@ $http->on(Constant::EVENT_START, function ($http) use ($payloadSize, $totalWorke
});
});
-$swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swooleAdapter, $registerRequestResources) {
+$swoole->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files, $swoole, $setRequestContext) {
Span::init('http.request');
$request = new Request($utopiaRequest->getSwooleRequest());
@@ -522,21 +519,18 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
return;
}
- $requestContainer = $swooleAdapter->getContainer();
- $requestContainer->set('container', fn () => $requestContainer);
- $requestContainer->set('request', fn () => $request);
- $requestContainer->set('response', fn () => $response);
+ $app = new Http($swoole, 'UTC');
+ $app->context()->set('request', fn () => $request);
+ $app->context()->set('response', fn () => $response);
+ $app->context()->set('utopia', fn () => $app);
- $app = new Http($swooleAdapter, 'UTC');
- $requestContainer->set('utopia', fn () => $app);
-
- $registerRequestResources($requestContainer);
+ $setRequestContext($app->context());
$app->setCompression(System::getEnv('_APP_COMPRESSION_ENABLED', 'enabled') === 'enabled');
$app->setCompressionMinSize(intval(System::getEnv('_APP_COMPRESSION_MIN_SIZE_BYTES', '1024'))); // 1KB
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $app->context()->get('authorization');
$request->setAuthorization($authorization);
$response->setAuthorization($authorization);
@@ -552,18 +546,18 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
$version = System::getEnv('_APP_VERSION', 'UNKNOWN');
- $logger = $app->getResource("logger");
+ $logger = $app->context()->get("logger");
if ($logger) {
try {
/** @var Utopia\Database\Document $user */
- $user = $app->getResource('user');
+ $user = $app->context()->get('user');
} catch (\Throwable $_th) {
// All good, user is optional information for logger
}
$route = $app->getRoute();
- $log = $app->getResource("log");
+ $log = $app->context()->get("log");
if (isset($user) && !$user->isEmpty()) {
$log->setUser(new User($user->getId()));
@@ -642,18 +636,16 @@ $swooleAdapter->onRequest(function ($utopiaRequest, $utopiaResponse) use ($files
});
// Fetch domains every `DOMAIN_SYNC_TIMER` seconds and update in the memory
-$http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
+$http->on(Constant::EVENT_TASK, function () use ($container) {
$lastSyncUpdate = null;
- $app = new Http($swooleAdapter, 'UTC');
-
/** @var Utopia\Database\Database $dbForPlatform */
- $dbForPlatform = $app->getResource('dbForPlatform');
+ $dbForPlatform = $container->get('dbForPlatform');
/** @var \Swoole\Table $riskyDomains */
- $riskyDomains = $app->getResource('riskyDomains');
+ $riskyDomains = $container->get('riskyDomains');
- Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $app) {
+ Timer::tick(DOMAIN_SYNC_TIMER * 1000, function () use ($dbForPlatform, $riskyDomains, &$lastSyncUpdate, $container) {
try {
$time = DateTime::now();
$limit = 1000;
@@ -670,7 +662,7 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
}
$results = [];
try {
- $authorization = $app->getResource('authorization');
+ $authorization = $container->get('authorization');
$results = $authorization->skip(fn () => $dbForPlatform->find('rules', $queries));
} catch (Throwable $th) {
Console::error('rules ' . $th->getMessage());
@@ -720,4 +712,4 @@ $http->on(Constant::EVENT_TASK, function () use ($swooleAdapter) {
});
});
-$swooleAdapter->start();
+$swoole->start();
diff --git a/app/init/constants.php b/app/init/constants.php
index 9dcdf09817..896d87ee2d 100644
--- a/app/init/constants.php
+++ b/app/init/constants.php
@@ -1,5 +1,11 @@
value, // legacy databases.createIndex
+ InsightType::TABLES_DB_INDEX->value, // tablesDB.createIndex
+ InsightType::DOCUMENTS_DB_INDEX->value, // documentsDB.createIndex
+ InsightType::VECTORS_DB_INDEX->value, // vectorsDB.createIndex
+ InsightType::DATABASE_PERFORMANCE->value,
+ InsightType::SITE_PERFORMANCE->value,
+ InsightType::SITE_ACCESSIBILITY->value,
+ InsightType::SITE_SEO->value,
+ InsightType::FUNCTION_PERFORMANCE->value,
+];
+
+// Public API services (SDK namespaces) that an insight CTA's `service` can reference.
+// Analyzers must pick the one matching the engine the resource lives in.
+const ADVISOR_CTA_SERVICES = [
+ InsightCTAService::DATABASES->value, // legacy
+ InsightCTAService::TABLES_DB->value,
+ InsightCTAService::DOCUMENTS_DB->value,
+ InsightCTAService::VECTORS_DB->value,
+];
+
+// Public API method names that an insight CTA's `method` can reference for index suggestions.
+const ADVISOR_CTA_METHODS = [
+ InsightCTAMethod::CREATE_INDEX->value,
+];
+
+// Insight severities
+const ADVISOR_SEVERITIES = [
+ InsightSeverity::INFO->value,
+ InsightSeverity::WARNING->value,
+ InsightSeverity::CRITICAL->value,
+];
+
+// Insight statuses
+const ADVISOR_STATUSES = [
+ InsightStatus::ACTIVE->value,
+ InsightStatus::DISMISSED->value,
+];
+
+// Report types
+const ADVISOR_REPORT_TYPES = [
+ ReportType::LIGHTHOUSE->value,
+ ReportType::AUDIT->value,
+ ReportType::DATABASE_ANALYZER->value,
+];
// Resource types for Tokens
const TOKENS_RESOURCE_TYPE_FILES = 'files';
diff --git a/app/init/database/filters.php b/app/init/database/filters.php
index 5a65479424..e171805c47 100644
--- a/app/init/database/filters.php
+++ b/app/init/database/filters.php
@@ -475,3 +475,17 @@ Database::addFilter(
]));
}
);
+
+Database::addFilter(
+ 'subQueryReportInsights',
+ function (mixed $value) {
+ return;
+ },
+ function (mixed $value, Document $document, Database $database) {
+ return $database->getAuthorization()->skip(fn () => $database->find('insights', [
+ Query::equal('projectInternalId', [$document->getAttribute('projectInternalId')]),
+ Query::equal('reportInternalId', [$document->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]));
+ }
+);
diff --git a/app/init/models.php b/app/init/models.php
index 7e70452ca1..0d1cb061ea 100644
--- a/app/init/models.php
+++ b/app/init/models.php
@@ -92,6 +92,8 @@ use Appwrite\Utopia\Response\Model\HealthTime;
use Appwrite\Utopia\Response\Model\HealthVersion;
use Appwrite\Utopia\Response\Model\Identity;
use Appwrite\Utopia\Response\Model\Index;
+use Appwrite\Utopia\Response\Model\Insight;
+use Appwrite\Utopia\Response\Model\InsightCTA;
use Appwrite\Utopia\Response\Model\Installation;
use Appwrite\Utopia\Response\Model\JWT;
use Appwrite\Utopia\Response\Model\Key;
@@ -174,12 +176,16 @@ use Appwrite\Utopia\Response\Model\PolicyUserLimit;
use Appwrite\Utopia\Response\Model\Preferences;
use Appwrite\Utopia\Response\Model\Presence;
use Appwrite\Utopia\Response\Model\Project;
+use Appwrite\Utopia\Response\Model\ProjectAuthMethod;
+use Appwrite\Utopia\Response\Model\ProjectProtocol;
+use Appwrite\Utopia\Response\Model\ProjectService;
use Appwrite\Utopia\Response\Model\Provider;
use Appwrite\Utopia\Response\Model\ProviderRepository;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework;
use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime;
use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList;
+use Appwrite\Utopia\Response\Model\Report;
use Appwrite\Utopia\Response\Model\ResourceToken;
use Appwrite\Utopia\Response\Model\Row;
use Appwrite\Utopia\Response\Model\Rule;
@@ -291,6 +297,8 @@ Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICA
Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT));
Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION));
Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING));
+Response::setModel(new BaseList('Insights List', Response::MODEL_INSIGHT_LIST, 'insights', Response::MODEL_INSIGHT));
+Response::setModel(new BaseList('Reports List', Response::MODEL_REPORT_LIST, 'reports', Response::MODEL_REPORT));
// Entities
Response::setModel(new Database());
@@ -401,6 +409,9 @@ Response::setModel(new FrameworkAdapter());
Response::setModel(new Deployment());
Response::setModel(new Execution());
Response::setModel(new Project());
+Response::setModel(new ProjectAuthMethod());
+Response::setModel(new ProjectService());
+Response::setModel(new ProjectProtocol());
Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new EphemeralKey());
@@ -514,6 +525,9 @@ Response::setModel(new Target());
Response::setModel(new Migration());
Response::setModel(new MigrationReport());
Response::setModel(new MigrationFirebaseProject());
+Response::setModel(new Insight());
+Response::setModel(new InsightCTA());
+Response::setModel(new Report());
// Tests (keep last)
Response::setModel(new Mock());
diff --git a/app/init/resources.php b/app/init/resources.php
index 96457294de..a626b612cb 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -2,12 +2,16 @@
use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Audit as AuditPublisher;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
@@ -112,6 +116,18 @@ $container->set('publisherForStatsResources', fn (Publisher $publisher) => new S
$publisher,
new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
), ['publisher']);
+$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForMails', fn (Publisher $publisher) => new MailPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
+), ['publisher']);
+$container->set('publisherForMessaging', fn (Publisher $publisher) => new MessagingPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+), ['publisher']);
/**
* Platform configuration
@@ -198,6 +214,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) {
return $cache;
}, ['pools', 'telemetry']);
+$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string {
+ return \sprintf('private, max-age=%d', $config->maxAge);
+});
+
$container->set('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
index 70d691370d..68a5a3edf5 100644
--- a/app/init/resources/request.php
+++ b/app/init/resources/request.php
@@ -4,14 +4,11 @@ use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Databases\TransactionState;
-use Appwrite\Event\Build;
use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
@@ -62,26 +59,18 @@ use Utopia\Validator\WhiteList;
* These resources depend (directly or transitively) on request/response
* and must be fresh for each HTTP request.
*/
-return function (Container $container): void {
- $container->set('utopia:graphql', function ($utopia) {
- return $utopia;
- }, ['utopia']);
+return function (Container $context): void {
+ $context->set('utopia:graphql', fn ($utopia) => $utopia, ['utopia']);
- $container->set('log', fn () => new Log(), []);
+ $context->set('log', fn () => new Log(), []);
- $container->set('logger', function ($register) {
- return $register->get('logger');
- }, ['register']);
+ $context->set('logger', fn ($register) => $register->get('logger'), ['register']);
- $container->set('authorization', function () {
- return new Authorization();
- }, []);
+ $context->set('authorization', fn () => new Authorization(), []);
- $container->set('store', function (): Store {
- return new Store();
- }, []);
+ $context->set('store', fn (): Store => new Store(), []);
- $container->set('proofForPassword', function (): Password {
+ $context->set('proofForPassword', function (): Password {
$hash = new Argon2();
$hash
->setMemoryCost(7168)
@@ -95,21 +84,21 @@ return function (Container $container): void {
return $password;
});
- $container->set('proofForToken', function (): Token {
+ $context->set('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
- $container->set('proofForCode', function (): Code {
+ $context->set('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
- $container->set('locale', function () {
+ $context->set('locale', function () {
$locale = new Locale(System::getEnv('_APP_LOCALE', 'en'));
$locale->setFallback(System::getEnv('_APP_LOCALE', 'en'));
@@ -117,41 +106,16 @@ return function (Container $container): void {
});
// Per-request queue resources (stateful, accumulate event data during request)
- $container->set('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
- }, ['publisher']);
- $container->set('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
- }, ['publisher']);
- $container->set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
- $container->set('queueForDatabase', function (Publisher $publisher) {
- return new EventDatabase($publisher);
- }, ['publisher']);
- $container->set('queueForDeletes', function (Publisher $publisher) {
- return new Delete($publisher);
- }, ['publisher']);
- $container->set('queueForEvents', function (Publisher $publisher) {
- return new Event($publisher);
- }, ['publisher']);
- $container->set('queueForWebhooks', function (Publisher $publisher) {
- return new Webhook($publisher);
- }, ['publisher']);
- $container->set('queueForRealtime', function () {
- return new Realtime();
- }, []);
- $container->set('usage', function () {
- return new UsageContext();
- }, []);
- $container->set('auditContext', fn () => new AuditContext(), []);
- $container->set('queueForFunctions', function (Publisher $publisher) {
- return new Func($publisher);
- }, ['publisher']);
- $container->set('eventProcessor', function () {
- return new EventProcessor();
- }, []);
- $container->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $context->set('queueForDatabase', fn (Publisher $publisher) => new EventDatabase($publisher), ['publisher']);
+ $context->set('queueForDeletes', fn (Publisher $publisher) => new Delete($publisher), ['publisher']);
+ $context->set('queueForEvents', fn (Publisher $publisher) => new Event($publisher), ['publisher']);
+ $context->set('queueForWebhooks', fn (Publisher $publisher) => new Webhook($publisher), ['publisher']);
+ $context->set('queueForRealtime', fn () => new Realtime(), []);
+ $context->set('usage', fn () => new UsageContext(), []);
+ $context->set('auditContext', fn () => new AuditContext(), []);
+ $context->set('queueForFunctions', fn (Publisher $publisher) => new Func($publisher), ['publisher']);
+ $context->set('eventProcessor', fn () => new EventProcessor(), []);
+ $context->set('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = new DatabasePool($pools->get('console'));
$database = new Database($adapter, $cache);
@@ -169,7 +133,7 @@ return function (Container $container): void {
return $database;
}, ['pools', 'cache', 'authorization']);
- $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
+ $context->set('getProjectDB', function (Group $pools, Database $dbForPlatform, Cache $cache, Authorization $authorization) {
$adapters = [];
return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$adapters) {
@@ -226,7 +190,7 @@ return function (Container $container): void {
};
}, ['pools', 'dbForPlatform', 'cache', 'authorization']);
- $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
+ $context->set('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
@@ -258,7 +222,7 @@ return function (Container $container): void {
/**
* List of allowed request hostnames for the request.
*/
- $container->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
+ $context->set('allowedHostnames', function (array $platform, Document $project, Document $rule, Document $devKey, Request $request) {
$allowed = [...($platform['hostnames'] ?? [])];
/* Add platform configured hostnames */
@@ -302,7 +266,7 @@ return function (Container $container): void {
/**
* List of allowed request schemes for the request.
*/
- $container->set('allowedSchemes', function (array $platform, Document $project) {
+ $context->set('allowedSchemes', function (array $platform, Document $project) {
$allowed = [...($platform['schemas'] ?? [])];
if (! $project->isEmpty() && $project->getId() !== 'console') {
@@ -322,7 +286,7 @@ return function (Container $container): void {
/**
* Whether the request origin is verified against the request hostname.
*/
- $container->set('domainVerification', function (Request $request) {
+ $context->set('domainVerification', function (Request $request) {
$origin = \parse_url($request->getOrigin($request->getReferer('')), PHP_URL_HOST);
$selfDomain = new Domain($request->getHostname());
$endDomain = new Domain((string) $origin);
@@ -334,7 +298,7 @@ return function (Container $container): void {
/**
* Cookie domain for the current request.
*/
- $container->set('cookieDomain', function (Request $request, Document $project) {
+ $context->set('cookieDomain', function (Request $request, Document $project) {
$localHosts = ['localhost', 'localhost:' . $request->getPort()];
$migrationHost = System::getEnv('_APP_MIGRATION_HOST');
@@ -368,7 +332,7 @@ return function (Container $container): void {
/**
* Rule associated with a request origin.
*/
- $container->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
+ $context->set('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) {
$domain = \parse_url($request->getOrigin(), PHP_URL_HOST);
if (empty($domain)) {
@@ -418,7 +382,7 @@ return function (Container $container): void {
/**
* CORS service
*/
- $container->set('cors', function (array $allowedHostnames) {
+ $context->set('cors', function (array $allowedHostnames) {
$corsConfig = Config::getParam('cors');
return new Cors(
@@ -430,23 +394,23 @@ return function (Container $container): void {
);
}, ['allowedHostnames']);
- $container->set('originValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- if (! $devKey->isEmpty()) {
- return new URL();
- }
+ $context->set(
+ 'originValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Origin($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
- return new Origin($allowedHostnames, $allowedSchemes);
- }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
+ $context->set(
+ 'redirectValidator',
+ fn (Document $devKey, array $allowedHostnames, array $allowedSchemes) => $devKey->isEmpty()
+ ? new Redirect($allowedHostnames, $allowedSchemes)
+ : new URL(),
+ ['devKey', 'allowedHostnames', 'allowedSchemes']
+ );
- $container->set('redirectValidator', function (Document $devKey, array $allowedHostnames, array $allowedSchemes) {
- if (! $devKey->isEmpty()) {
- return new URL();
- }
-
- return new Redirect($allowedHostnames, $allowedSchemes);
- }, ['devKey', 'allowedHostnames', 'allowedSchemes']);
-
- $container->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
+ $context->set('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken, $authorization) {
/**
* Handles user authentication and session validation.
*
@@ -617,7 +581,7 @@ return function (Container $container): void {
return $user;
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken', 'authorization']);
- $container->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
+ $context->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia) {
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
@@ -650,7 +614,7 @@ return function (Container $container): void {
return $project;
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia']);
- $container->set('session', function (User $user, Store $store, Token $proofForToken) {
+ $context->set('session', function (User $user, Store $store, Token $proofForToken) {
if ($user->isEmpty()) {
return;
}
@@ -671,7 +635,7 @@ return function (Container $container): void {
return;
}, ['user', 'store', 'proofForToken']);
- $container->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
+ $context->set('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) {
if ($project->isEmpty() || $project->getId() === 'console') {
return $dbForPlatform;
}
@@ -947,7 +911,7 @@ return function (Container $container): void {
return $database;
}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']);
- $container->set('schema', function ($utopia, $dbForProject, $authorization) {
+ $context->set('schema', function ($utopia, $dbForProject, $authorization) {
$complexity = function (int $complexity, array $args) {
$queries = Query::parseQueries($args['queries'] ?? []);
@@ -1034,13 +998,9 @@ return function (Container $container): void {
);
}, ['utopia', 'dbForProject', 'authorization']);
- $container->set('audit', function ($dbForProject) {
- $adapter = new AdapterDatabase($dbForProject);
+ $context->set('audit', fn ($dbForProject) => new Audit(new AdapterDatabase($dbForProject)), ['dbForProject']);
- return new Audit($adapter);
- }, ['dbForProject']);
-
- $container->set('mode', function ($request, Document $project) {
+ $context->set('mode', function ($request, Document $project) {
/** @var Appwrite\Utopia\Request $request */
/**
@@ -1058,7 +1018,7 @@ return function (Container $container): void {
return $mode;
}, ['request', 'project']);
- $container->set('requestTimestamp', function ($request) {
+ $context->set('requestTimestamp', function ($request) {
// TODO: Move this to the Request class itself
$timestampHeader = $request->getHeader('x-appwrite-timestamp');
$requestTimestamp = null;
@@ -1073,7 +1033,7 @@ return function (Container $container): void {
return $requestTimestamp;
}, ['request']);
- $container->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
+ $context->set('devKey', function (Request $request, Document $project, array $servers, Database $dbForPlatform, Authorization $authorization) {
$devKey = $request->getHeader('x-appwrite-dev-key', $request->getParam('devKey', ''));
// Check if given key match project's development keys
@@ -1122,7 +1082,7 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'servers', 'dbForPlatform', 'authorization']);
- $container->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
+ $context->set('team', function (Document $project, Database $dbForPlatform, Http $utopia, Request $request, Authorization $authorization) {
$teamInternalId = '';
if ($project->getId() !== 'console') {
$teamInternalId = $project->getAttribute('teamInternalId', '');
@@ -1165,7 +1125,7 @@ return function (Container $container): void {
return $team;
}, ['project', 'dbForPlatform', 'utopia', 'request', 'authorization']);
- $container->set('previewHostname', function (Request $request, ?Key $apiKey) {
+ $context->set('previewHostname', function (Request $request, ?Key $apiKey) {
$allowed = false;
if (Http::isDevelopment()) {
@@ -1184,7 +1144,7 @@ return function (Container $container): void {
return '';
}, ['request', 'apiKey']);
- $container->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
+ $context->set('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key {
$key = $request->getHeader('x-appwrite-key');
if (empty($key)) {
@@ -1218,7 +1178,7 @@ return function (Container $container): void {
return $key;
}, ['request', 'project', 'team', 'user']);
- $container->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
+ $context->set('resourceToken', function ($project, $dbForProject, $request, Authorization $authorization) {
$tokenJWT = $request->getParam('token');
if (! empty($tokenJWT) && ! $project->isEmpty()) { // JWT authentication
@@ -1285,10 +1245,10 @@ return function (Container $container): void {
return new Document([]);
}, ['project', 'dbForProject', 'request', 'authorization']);
- $container->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
+ $context->set('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) {
return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database {
- $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', ''));
+ $databaseDSN = $database->getAttribute('database') ?: $project->getAttribute('database', '');
$databaseType = $database->getAttribute('type', '');
try {
@@ -1447,35 +1407,27 @@ return function (Container $container): void {
}, ['pools', 'cache', 'project', 'request', 'usage', 'authorization']);
- $container->set('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) {
- return new TransactionState($dbForProject, $authorization, $getDatabasesDB);
- }, ['dbForProject', 'authorization', 'getDatabasesDB']);
+ $context->set(
+ 'transactionState',
+ fn (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) => new TransactionState($dbForProject, $authorization, $getDatabasesDB),
+ ['dbForProject', 'authorization', 'getDatabasesDB']
+ );
- $container->set('executionsRetentionCount', function (Document $project, array $plan) {
- if ($project->getId() === 'console' || empty($plan)) {
- return 0;
- }
+ $context->set(
+ 'executionsRetentionCount',
+ fn (Document $project, array $plan) => ($project->getId() === 'console' || empty($plan))
+ ? 0
+ : (int) ($plan['executionsRetentionCount'] ?? 100),
+ ['project', 'plan']
+ );
- return (int) ($plan['executionsRetentionCount'] ?? 100);
- }, ['project', 'plan']);
+ $context->set('deviceForFiles', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForSites', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForMigrations', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForFunctions', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId())), ['project', 'telemetry']);
+ $context->set('deviceForBuilds', fn ($project, Telemetry $telemetry) => new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId())), ['project', 'telemetry']);
- $container->set('deviceForFiles', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForSites', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_SITES . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForMigrations', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_IMPORTS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForFunctions', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_FUNCTIONS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
- $container->set('deviceForBuilds', function ($project, Telemetry $telemetry) {
- return new Device\Telemetry($telemetry, getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()));
- }, ['project', 'telemetry']);
-
- $container->set('embeddingAgent', function ($register) {
+ $context->set('embeddingAgent', function ($register) {
$adapter = new Ollama();
$adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed'));
$adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000'));
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
index 17796fadcd..791bf5edf0 100644
--- a/app/init/worker/message.php
+++ b/app/init/worker/message.php
@@ -1,12 +1,9 @@
set('queueForMessaging', function (Publisher $publisher) {
- return new Messaging($publisher);
- }, ['publisher']);
-
- $container->set('queueForMails', function (Publisher $publisher) {
- return new Mail($publisher);
- }, ['publisher']);
-
- $container->set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
-
$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index 1bf36b7f6d..6ce1fb5cea 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -881,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -909,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
diff --git a/composer.json b/composer.json
index 9a84be6111..1696a57d05 100644
--- a/composer.json
+++ b/composer.json
@@ -51,7 +51,7 @@
"ext-sockets": "*",
"appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
- "utopia-php/abuse": "1.2.*",
+ "utopia-php/abuse": "1.3.*",
"utopia-php/agents": "1.2.*",
"utopia-php/analytics": "0.15.*",
"utopia-php/audit": "2.2.*",
@@ -67,15 +67,15 @@
"utopia-php/emails": "0.6.*",
"utopia-php/dns": "1.6.*",
"utopia-php/dsn": "0.2.1",
- "utopia-php/http": "0.34.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/http": "^2.0@RC",
+ "utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
- "utopia-php/logger": "0.6.*",
+ "utopia-php/logger": "0.8.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.*",
- "utopia-php/platform": "0.13.*",
+ "utopia-php/platform": "^1.0@RC",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
"utopia-php/preloader": "0.2.*",
diff --git a/composer.lock b/composer.lock
index bbf0d59a96..7e39b0e774 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ec2ad489c60f0102f0dfab223b6d1fe4",
+ "content-hash": "9db08148f3a8f53bd972eb7b3a835b3b",
"packages": [
{
"name": "adhocore/jwt",
@@ -69,25 +69,25 @@
},
{
"name": "appwrite/appwrite",
- "version": "19.1.0",
+ "version": "23.1.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-for-php.git",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56"
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56",
- "reference": "8738e812062f899c85b2598eef43d6a247f08a56",
+ "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa",
+ "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
- "php": ">=7.1.0"
+ "php": ">=8.2.0"
},
"require-dev": {
- "mockery/mockery": "^1.6.12",
+ "mockery/mockery": "1.6.12",
"phpunit/phpunit": "^10"
},
"type": "library",
@@ -100,14 +100,14 @@
"license": [
"BSD-3-Clause"
],
- "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API",
+ "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API",
"support": {
"email": "team@appwrite.io",
"issues": "https://github.com/appwrite/sdk-for-php/issues",
- "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0",
+ "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0",
"url": "https://appwrite.io/support"
},
- "time": "2025-12-18T08:07:43+00:00"
+ "time": "2026-05-08T13:44:58+00:00"
},
{
"name": "appwrite/php-clamav",
@@ -2641,16 +2641,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -2663,7 +2663,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2688,7 +2688,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2699,12 +2699,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/http-client",
@@ -2809,16 +2813,16 @@
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -2831,7 +2835,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2867,7 +2871,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2878,12 +2882,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "symfony/polyfill-mbstring",
@@ -3212,16 +3220,16 @@
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -3239,7 +3247,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3275,7 +3283,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -3295,7 +3303,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "tbachert/spi",
@@ -3351,24 +3359,24 @@
},
{
"name": "utopia-php/abuse",
- "version": "1.2.3",
+ "version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
- "reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c",
- "reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
+ "url": "https://api.github.com/repos/utopia-php/abuse/zipball/5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
+ "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-pdo": "*",
"ext-redis": "*",
- "php": ">=8.0",
+ "php": ">=8.2",
"utopia-php/database": "5.*"
},
"require-dev": {
@@ -3397,27 +3405,27 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
- "source": "https://github.com/utopia-php/abuse/tree/1.2.3"
+ "source": "https://github.com/utopia-php/abuse/tree/1.3.0"
},
- "time": "2026-04-29T11:19:08+00:00"
+ "time": "2026-05-11T08:07:02+00:00"
},
{
"name": "utopia-php/agents",
- "version": "1.2.1",
+ "version": "1.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/agents.git",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e"
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e",
+ "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634",
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1.0"
},
"require-dev": {
"laravel/pint": "^1.18",
@@ -3450,9 +3458,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/agents/issues",
- "source": "https://github.com/utopia-php/agents/tree/1.2.1"
+ "source": "https://github.com/utopia-php/agents/tree/1.2.2"
},
- "time": "2026-02-24T06:03:55+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/analytics",
@@ -3502,22 +3510,22 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.2",
+ "version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57"
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57",
- "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57",
+ "url": "https://api.github.com/repos/utopia-php/audit/zipball/95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/database": "5.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
@@ -3545,9 +3553,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
- "source": "https://github.com/utopia-php/audit/tree/2.2.2"
+ "source": "https://github.com/utopia-php/audit/tree/2.2.3"
},
- "time": "2026-05-04T06:48:58+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/auth",
@@ -3606,16 +3614,16 @@
},
{
"name": "utopia-php/cache",
- "version": "1.0.1",
+ "version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c"
+ "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c",
- "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c",
+ "url": "https://api.github.com/repos/utopia-php/cache/zipball/ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa",
+ "reference": "ef52a04e8bfa314c621e3d3326ffcf50db3dfdfa",
"shasum": ""
},
"require": {
@@ -3652,9 +3660,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/1.0.1"
+ "source": "https://github.com/utopia-php/cache/tree/1.0.3"
},
- "time": "2026-03-12T03:39:09+00:00"
+ "time": "2026-05-11T11:02:13+00:00"
},
{
"name": "utopia-php/cli",
@@ -4172,22 +4180,21 @@
},
{
"name": "utopia-php/emails",
- "version": "0.6.9",
+ "version": "0.6.10",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/emails.git",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf"
+ "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
+ "url": "https://api.github.com/repos/utopia-php/emails/zipball/2e397754ce68c2ba918564b9f31d9923c0a90429",
+ "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/domains": "^1.0",
- "utopia-php/fetch": "^0.5",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4195,7 +4202,8 @@
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.3",
"utopia-php/cli": "^0.22",
- "utopia-php/console": "0.*"
+ "utopia-php/console": "0.*",
+ "utopia-php/fetch": "^1.1"
},
"type": "library",
"autoload": {
@@ -4227,22 +4235,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/emails/issues",
- "source": "https://github.com/utopia-php/emails/tree/0.6.9"
+ "source": "https://github.com/utopia-php/emails/tree/0.6.10"
},
- "time": "2026-03-14T13:52:56+00:00"
+ "time": "2026-05-08T10:16:22+00:00"
},
{
"name": "utopia-php/fetch",
- "version": "0.5.1",
+ "version": "1.1.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/fetch.git",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5",
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5",
"shasum": ""
},
"require": {
@@ -4251,7 +4259,8 @@
"require-dev": {
"laravel/pint": "^1.5.0",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "swoole/ide-helper": "^6.0"
},
"type": "library",
"autoload": {
@@ -4266,22 +4275,22 @@
"description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
"issues": "https://github.com/utopia-php/fetch/issues",
- "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
+ "source": "https://github.com/utopia-php/fetch/tree/1.1.2"
},
- "time": "2025-12-18T16:25:10+00:00"
+ "time": "2026-04-29T11:19:19+00:00"
},
{
"name": "utopia-php/http",
- "version": "0.34.25",
+ "version": "2.0.0-rc1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
- "reference": "76be330d4197bae680eb4ccc29c573456fe91904"
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/http/zipball/76be330d4197bae680eb4ccc29c573456fe91904",
- "reference": "76be330d4197bae680eb4ccc29c573456fe91904",
+ "url": "https://api.github.com/repos/utopia-php/http/zipball/3e3b431d443844c6bf810120dee735f45880856f",
+ "reference": "3e3b431d443844c6bf810120dee735f45880856f",
"shasum": ""
},
"require": {
@@ -4322,9 +4331,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
- "source": "https://github.com/utopia-php/http/tree/0.34.25"
+ "source": "https://github.com/utopia-php/http/tree/2.0.0-rc1"
},
- "time": "2026-05-05T04:39:15+00:00"
+ "time": "2026-05-05T15:00:03+00:00"
},
{
"name": "utopia-php/image",
@@ -4426,20 +4435,21 @@
},
{
"name": "utopia-php/logger",
- "version": "0.6.2",
+ "version": "0.8.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/logger.git",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d"
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d",
+ "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b",
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.1",
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -4474,9 +4484,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/logger/issues",
- "source": "https://github.com/utopia-php/logger/tree/0.6.2"
+ "source": "https://github.com/utopia-php/logger/tree/0.8.0"
},
- "time": "2024-10-14T16:02:49+00:00"
+ "time": "2026-05-05T06:04:27+00:00"
},
{
"name": "utopia-php/messaging",
@@ -4531,24 +4541,24 @@
},
{
"name": "utopia-php/migration",
- "version": "1.10.0",
+ "version": "1.11.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea"
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/55f4863d690e775f44fec3cae4bd1f4491fed5ea",
- "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
+ "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1",
"shasum": ""
},
"require": {
- "appwrite/appwrite": "19.*",
+ "appwrite/appwrite": "23.*",
"ext-curl": "*",
"ext-openssl": "*",
"halaxa/json-machine": "^1.2",
- "php": ">=8.1",
+ "php": ">=8.2",
"utopia-php/database": "5.*",
"utopia-php/dsn": "0.2.*",
"utopia-php/storage": "2.*"
@@ -4580,9 +4590,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/1.10.0"
+ "source": "https://github.com/utopia-php/migration/tree/1.11.0"
},
- "time": "2026-05-06T04:35:32+00:00"
+ "time": "2026-05-11T08:13:06+00:00"
},
{
"name": "utopia-php/mongo",
@@ -4647,16 +4657,16 @@
},
{
"name": "utopia-php/platform",
- "version": "0.13.2",
+ "version": "1.0.0-rc1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/platform.git",
- "reference": "a20cb8b20a1e4c9886309c2d033a0292ba0937b9"
+ "reference": "36c0a8b2f3d96ca056d724701a302a127111e933"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/platform/zipball/a20cb8b20a1e4c9886309c2d033a0292ba0937b9",
- "reference": "a20cb8b20a1e4c9886309c2d033a0292ba0937b9",
+ "url": "https://api.github.com/repos/utopia-php/platform/zipball/36c0a8b2f3d96ca056d724701a302a127111e933",
+ "reference": "36c0a8b2f3d96ca056d724701a302a127111e933",
"shasum": ""
},
"require": {
@@ -4664,7 +4674,7 @@
"ext-redis": "*",
"php": ">=8.3",
"utopia-php/cli": "0.23.3",
- "utopia-php/http": "0.34.25",
+ "utopia-php/http": "^2.0@RC",
"utopia-php/queue": "0.18.2",
"utopia-php/servers": "0.4.0"
},
@@ -4692,9 +4702,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/platform/issues",
- "source": "https://github.com/utopia-php/platform/tree/0.13.2"
+ "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc1"
},
- "time": "2026-05-05T06:00:26+00:00"
+ "time": "2026-05-05T15:09:27+00:00"
},
{
"name": "utopia-php/pools",
@@ -5228,23 +5238,23 @@
},
{
"name": "utopia-php/vcs",
- "version": "3.2.0",
+ "version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3"
+ "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3",
+ "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03ccd12b75d67d29094eb760b468fddde4b6b5e5",
+ "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5",
"shasum": ""
},
"require": {
"adhocore/jwt": "^1.1",
"php": ">=8.0",
"utopia-php/cache": "1.0.*",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.*.*",
@@ -5271,9 +5281,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
- "source": "https://github.com/utopia-php/vcs/tree/3.2.0"
+ "source": "https://github.com/utopia-php/vcs/tree/3.2.1"
},
- "time": "2026-04-08T16:00:31+00:00"
+ "time": "2026-05-08T10:13:53+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5466,16 +5476,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.27.5",
+ "version": "1.29.2",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb"
+ "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/31248a984a4d478d20a780dda8f5897984ee4e8f",
+ "reference": "31248a984a4d478d20a780dda8f5897984ee4e8f",
"shasum": ""
},
"require": {
@@ -5511,9 +5521,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
- "source": "https://github.com/appwrite/sdk-generator/tree/1.27.5"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.29.2"
},
- "time": "2026-05-05T12:09:40+00:00"
+ "time": "2026-05-13T04:47:38+00:00"
},
{
"name": "brianium/paratest",
@@ -8445,7 +8455,10 @@
],
"aliases": [],
"minimum-stability": "dev",
- "stability-flags": {},
+ "stability-flags": {
+ "utopia-php/http": 5,
+ "utopia-php/platform": 5
+ },
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
diff --git a/docker-compose.yml b/docker-compose.yml
index da5efac438..76f06c672a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -255,7 +255,7 @@ services:
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: appwrite/console:7.8.45
+ image: appwrite/console:8
restart: unless-stopped
networks:
- appwrite
@@ -1114,6 +1114,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1145,6 +1152,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1478,4 +1492,4 @@ volumes:
appwrite-sites:
appwrite-builds:
appwrite-config:
- appwrite-models:
\ No newline at end of file
+ appwrite-models:
diff --git a/docs/references/advisor/delete-report.md b/docs/references/advisor/delete-report.md
new file mode 100644
index 0000000000..b32ba845e2
--- /dev/null
+++ b/docs/references/advisor/delete-report.md
@@ -0,0 +1 @@
+Delete an analyzer report by its unique ID. Nested insights and CTA metadata are removed asynchronously by the deletes worker.
diff --git a/docs/references/advisor/get-insight.md b/docs/references/advisor/get-insight.md
new file mode 100644
index 0000000000..7e1e795c22
--- /dev/null
+++ b/docs/references/advisor/get-insight.md
@@ -0,0 +1 @@
+Get an insight by its unique ID, scoped to its parent report.
diff --git a/docs/references/advisor/get-report.md b/docs/references/advisor/get-report.md
new file mode 100644
index 0000000000..731c10dc8a
--- /dev/null
+++ b/docs/references/advisor/get-report.md
@@ -0,0 +1 @@
+Get an analyzer report by its unique ID. The response includes the report's metadata and the nested insights it produced.
diff --git a/docs/references/advisor/list-insights.md b/docs/references/advisor/list-insights.md
new file mode 100644
index 0000000000..56d6a2fca0
--- /dev/null
+++ b/docs/references/advisor/list-insights.md
@@ -0,0 +1 @@
+List the insights produced under a single analyzer report. You can use the query params to filter your results further.
diff --git a/docs/references/advisor/list-reports.md b/docs/references/advisor/list-reports.md
new file mode 100644
index 0000000000..04b91c541a
--- /dev/null
+++ b/docs/references/advisor/list-reports.md
@@ -0,0 +1 @@
+Get a list of all the project's analyzer reports. You can use the query params to filter your results.
diff --git a/docs/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md
index eea1795a3e..b614c2ad13 100644
--- a/docs/references/vcs/list-repository-branches.md
+++ b/docs/references/vcs/list-repository-branches.md
@@ -1 +1 @@
-Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
+Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
diff --git a/docs/services/advisor.md b/docs/services/advisor.md
new file mode 100644
index 0000000000..2fa3943829
--- /dev/null
+++ b/docs/services/advisor.md
@@ -0,0 +1,3 @@
+The Advisor service provides read access to analyzer reports and their nested insights for a project.
+
+Use the reports endpoints to list and fetch analyzer runs, then use the insights endpoints to inspect individual findings attached to a report.
diff --git a/phpunit.xml b/phpunit.xml
index 9748c5a5c8..32e865fe35 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -38,6 +38,7 @@
./tests/e2e/Services/Messaging
./tests/e2e/Services/Migrations
./tests/e2e/Services/Project
+ ./tests/e2e/Services/Advisor
./tests/e2e/Services/Functions/FunctionsBase.php
./tests/e2e/Services/Functions/FunctionsCustomServerTest.php
./tests/e2e/Services/Functions/FunctionsCustomClientTest.php
diff --git a/src/Appwrite/Advisor/Validator/CTAs.php b/src/Appwrite/Advisor/Validator/CTAs.php
new file mode 100644
index 0000000000..14f7d788e7
--- /dev/null
+++ b/src/Appwrite/Advisor/Validator/CTAs.php
@@ -0,0 +1,83 @@
+allowedServices = $allowedServices ?? ADVISOR_CTA_SERVICES;
+ $this->allowedMethods = $allowedMethods ?? ADVISOR_CTA_METHODS;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->message;
+ }
+
+ public function isArray(): bool
+ {
+ return true;
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE_ARRAY;
+ }
+
+ public function isValid($value): bool
+ {
+ if (!\is_array($value)) {
+ return false;
+ }
+
+ if (\count($value) > $this->maxCount) {
+ $this->message = "A maximum of {$this->maxCount} CTAs are allowed per insight.";
+ return false;
+ }
+
+ foreach ($value as $entry) {
+ if (!\is_array($entry)) {
+ return false;
+ }
+
+ $maxLengths = ['label' => 256, 'service' => 64, 'method' => 64];
+ foreach ($maxLengths as $required => $maxLength) {
+ if (!isset($entry[$required]) || !\is_string($entry[$required]) || $entry[$required] === '') {
+ return false;
+ }
+ if (\strlen($entry[$required]) > $maxLength) {
+ $this->message = "CTA `{$required}` must not exceed {$maxLength} characters.";
+ return false;
+ }
+ }
+
+ if (!empty($this->allowedServices) && !\in_array($entry['service'], $this->allowedServices, true)) {
+ $this->message = "CTA `service` must be one of: " . \implode(', ', $this->allowedServices) . '.';
+ return false;
+ }
+
+ if (!empty($this->allowedMethods) && !\in_array($entry['method'], $this->allowedMethods, true)) {
+ $this->message = "CTA `method` must be one of: " . \implode(', ', $this->allowedMethods) . '.';
+ return false;
+ }
+
+ if (isset($entry['params']) && !\is_array($entry['params']) && !\is_object($entry['params'])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..1166a313c6 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -55,7 +55,7 @@ class Google extends OAuth2
'state' => \json_encode($this->state),
'response_type' => 'code',
'access_type' => 'offline',
- 'prompt' => 'consent'
+ 'prompt' => $this->getPrompt()
]);
}
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,54 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Extracts the prompt values from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getPrompt(): string
+ {
+ $secret = $this->getAppSecret();
+ $prompt = $secret['prompt'] ?? [];
+
+ if (empty($prompt)) {
+ $prompt = ['consent'];
+ }
+
+ return \implode(' ', $prompt);
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php
index 9b3d68519f..eb36e0d394 100644
--- a/src/Appwrite/Bus/Listeners/Mails.php
+++ b/src/Appwrite/Bus/Listeners/Mails.php
@@ -4,14 +4,14 @@ namespace Appwrite\Bus\Listeners;
use Appwrite\Auth\MFA\Type;
use Appwrite\Bus\Events\SessionCreated;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Template\Template;
use Utopia\Bus\Listener;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Locale\Locale;
-use Utopia\Queue\Publisher;
use Utopia\Storage\Validator\FileName;
use Utopia\System\System;
@@ -31,14 +31,14 @@ class Mails extends Listener
{
$this
->desc('Sends session alert emails')
- ->inject('publisher')
+ ->inject('publisherForMails')
->inject('locale')
->inject('platform')
->inject('dbForProject')
->callback($this->handle(...));
}
- public function handle(SessionCreated $event, Publisher $publisher, Locale $locale, array $platform, Database $dbForProject): void
+ public function handle(SessionCreated $event, MailPublisher $publisherForMails, Locale $locale, array $platform, Database $dbForProject): void
{
$project = new Document($event->project);
@@ -124,34 +124,32 @@ class Mails extends Listener
];
}
- $queueForMails = new Mail($publisher);
-
+ $smtpConfig = [];
if ($smtp['enabled'] ?? false) {
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '')
- ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility
- ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '')
- ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM))
- ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'));
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '', // Includes backwards compatibility
+ 'replyToName' => $customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '',
+ 'senderEmail' => $customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM),
+ 'senderName' => $customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'),
+ ];
}
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl')
- ->appendVariables($emailVariables)
- ->setRecipient($event->user['email']);
-
- if ($isBranded) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $event->user['email'],
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl',
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $isBranded ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
}
}
diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php
deleted file mode 100644
index 4eaf108f15..0000000000
--- a/src/Appwrite/Event/Build.php
+++ /dev/null
@@ -1,146 +0,0 @@
-setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME));
- }
-
- /**
- * Sets template for the build event.
- *
- * @param Document $template
- * @return self
- */
- public function setTemplate(Document $template): self
- {
- $this->template = $template;
-
- return $this;
- }
-
- /**
- * Sets resource document for the build event.
- *
- * @param Document $resource
- * @return self
- */
- public function setResource(Document $resource): self
- {
- $this->resource = $resource;
-
- return $this;
- }
-
- /**
- * Returns set resource document for the build event.
- *
- * @return null|Document
- */
- public function getResource(): ?Document
- {
- return $this->resource;
- }
-
- /**
- * Sets deployment for the build event.
- *
- * @param Document $deployment
- * @return self
- */
- public function setDeployment(Document $deployment): self
- {
- $this->deployment = $deployment;
-
- return $this;
- }
-
- /**
- * Returns set deployment for the build event.
- *
- * @return null|Document
- */
- public function getDeployment(): ?Document
- {
- return $this->deployment;
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Prepare payload for queue.
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'resource' => $this->resource,
- 'deployment' => $this->deployment,
- 'type' => $this->type,
- 'template' => $this->template,
- 'platform' => $platform,
- ];
- }
-
- /**
- * Resets event.
- *
- * @return self
- */
- public function reset(): self
- {
- $this->type = '';
- $this->resource = null;
- $this->deployment = null;
- $this->template = null;
- $this->platform = [];
- parent::reset();
-
- return $this;
- }
-}
diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php
deleted file mode 100644
index 0685586c60..0000000000
--- a/src/Appwrite/Event/Mail.php
+++ /dev/null
@@ -1,576 +0,0 @@
-setQueue(System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MAILS_CLASS_NAME', Event::MAILS_CLASS_NAME));
- }
-
- /**
- * Sets subject for the mail event.
- *
- * @param string $subject
- * @return self
- */
- public function setSubject(string $subject): self
- {
- $this->subject = $subject;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getSubject(): string
- {
- return $this->subject;
- }
-
- /**
- * Sets recipient for the mail event.
- *
- * @param string $recipient
- * @return self
- */
- public function setRecipient(string $recipient): self
- {
- $this->recipient = $recipient;
-
- return $this;
- }
-
- /**
- * Returns set recipient for mail event.
- *
- * @return string
- */
- public function getRecipient(): string
- {
- return $this->recipient;
- }
-
- /**
- * Sets body for the mail event.
- *
- * @param string $body
- * @return self
- */
- public function setBody(string $body): self
- {
- $this->body = $body;
-
- return $this;
- }
-
- /**
- * Returns body for the mail event.
- *
- * @return string
- */
- public function getBody(): string
- {
- return $this->body;
- }
-
- /**
- * Sets preview for the mail event.
- *
- * @return self
- */
- public function setPreview(string $preview): self
- {
- $this->preview = $preview;
-
- return $this;
- }
-
- /**
- * Returns preview for the mail event.
- *
- * @return string
- */
- public function getPreview(): string
- {
- return $this->preview;
- }
-
- /**
- * Sets name for the mail event.
- *
- * @param string $name
- * @return self
- */
- public function setName(string $name): self
- {
- $this->name = $name;
-
- return $this;
- }
-
- /**
- * Returns set name for the mail event.
- *
- * @return string
- */
- public function getName(): string
- {
- return $this->name;
- }
-
- /**
- * Sets bodyTemplate for the mail event.
- *
- * @param string $bodyTemplate
- * @return self
- */
- public function setBodyTemplate(string $bodyTemplate): self
- {
- $this->bodyTemplate = $bodyTemplate;
-
- return $this;
- }
-
- /**
- * Returns subject for the mail event.
- *
- * @return string
- */
- public function getBodyTemplate(): string
- {
- return $this->bodyTemplate;
- }
-
- /**
- * Set SMTP Host
- *
- * @param string $host
- * @return self
- */
- public function setSmtpHost(string $host): self
- {
- $this->smtp['host'] = $host;
- return $this;
- }
-
- /**
- * Set SMTP port
- *
- * @param int $port
- * @return self
- */
- public function setSmtpPort(int $port): self
- {
- $this->smtp['port'] = $port;
- return $this;
- }
-
- /**
- * Set SMTP username
- *
- * @param string $username
- * @return self
- */
- public function setSmtpUsername(string $username): self
- {
- $this->smtp['username'] = $username;
- return $this;
- }
-
- /**
- * Set SMTP password
- *
- * @param string $password
- * @return self
- */
- public function setSmtpPassword(string $password): self
- {
- $this->smtp['password'] = $password;
- return $this;
- }
-
- /**
- * Set SMTP secure
- *
- * @param string $secure
- * @return self
- */
- public function setSmtpSecure(string $secure): self
- {
- $this->smtp['secure'] = $secure;
- return $this;
- }
-
- /**
- * Set SMTP sender email
- *
- * @param string $senderEmail
- * @return self
- */
- public function setSmtpSenderEmail(string $senderEmail): self
- {
- $this->smtp['senderEmail'] = $senderEmail;
- return $this;
- }
-
- /**
- * Set SMTP sender name
- *
- * @param string $senderName
- * @return self
- */
- public function setSmtpSenderName(string $senderName): self
- {
- $this->smtp['senderName'] = $senderName;
- return $this;
- }
-
- /**
- * Set SMTP reply-to email
- *
- * @param string $email
- * @return self
- */
- public function setSmtpReplyToEmail(string $email): self
- {
- $this->smtp['replyToEmail'] = $email;
- return $this;
- }
-
- /**
- * Set SMTP reply-to name
- *
- * @param string $name
- * @return self
- */
- public function setSmtpReplyToName(string $name): self
- {
- $this->smtp['replyToName'] = $name;
- return $this;
- }
-
- /**
- * Get SMTP
- *
- * @return string
- */
- public function getSmtpHost(): string
- {
- return $this->smtp['host'] ?? '';
- }
-
- /**
- * Get SMTP port
- *
- * @return integer
- */
- public function getSmtpPort(): int
- {
- return $this->smtp['port'] ?? 0;
- }
-
- /**
- * Get SMTP username
- *
- * @return string
- */
- public function getSmtpUsername(): string
- {
- return $this->smtp['username'] ?? '';
- }
-
- /**
- * Get SMTP password
- *
- * @return string
- */
- public function getSmtpPassword(): string
- {
- return $this->smtp['password'] ?? '';
- }
-
- /**
- * Get SMTP secure
- *
- * @return string
- */
- public function getSmtpSecure(): string
- {
- return $this->smtp['secure'] ?? '';
- }
-
- /**
- * Get SMTP sender email
- *
- * @return string
- */
- public function getSmtpSenderEmail(): string
- {
- return $this->smtp['senderEmail'] ?? '';
- }
-
- /**
- * Get SMTP sender name
- *
- * @return string
- */
- public function getSmtpSenderName(): string
- {
- return $this->smtp['senderName'] ?? '';
- }
-
- /**
- * Get SMTP reply-to email
- *
- * @return string
- */
- public function getSmtpReplyToEmail(): string
- {
- return $this->smtp['replyToEmail'] ?? '';
- }
-
- /**
- * Get SMTP reply-to name
- *
- * @return string
- */
- public function getSmtpReplyToName(): string
- {
- return $this->smtp['replyToName'] ?? '';
- }
-
- /**
- * Get Email Variables
- *
- * @return array
- */
- public function getVariables(): array
- {
- return $this->variables;
- }
-
- /**
- * Set Email Variables
- *
- * @param array $variables
- * @return self
- */
- public function setVariables(array $variables): self
- {
- $this->variables = $variables;
- return $this;
- }
-
- /**
- * Append variables to the email event.
- *
- * @param array $variables
- * @return self
- */
- public function appendVariables(array $variables): self
- {
- $this->variables = \array_merge($this->variables, $variables);
- return $this;
- }
-
- /**
- * Set attachment
- * @param string $content
- * @param string $filename
- * @param string $encoding
- * @param string $type
- * @return self
- */
- public function setAttachment(string $content, string $filename, string $encoding = 'base64', string $type = 'plain/text')
- {
- $this->attachment = [
- 'content' => base64_encode($content),
- 'filename' => $filename,
- 'encoding' => $encoding,
- 'type' => $type,
- ];
- return $this;
- }
-
- /**
- * Get attachment
- *
- * @return array
- */
- public function getAttachment(): array
- {
- return $this->attachment;
- }
-
- /**
- * Reset attachment
- *
- * @return self
- */
- public function resetAttachment(): self
- {
- $this->attachment = [];
- return $this;
- }
-
- /**
- * Set sender email
- *
- * @param string $email
- * @return self
- */
- public function setSenderEmail(string $email): self
- {
- $this->customMailOptions['senderEmail'] = $email;
- return $this;
- }
-
- /**
- * Get sender email
- *
- * @return string
- */
- public function getSenderEmail(): string
- {
- return $this->customMailOptions['senderEmail'] ?? '';
- }
-
- /**
- * Set sender name
- *
- * @param string $name
- * @return self
- */
- public function setSenderName(string $name): self
- {
- $this->customMailOptions['senderName'] = $name;
- return $this;
- }
-
- /**
- * Get sender name
- *
- * @return string
- */
- public function getSenderName(): string
- {
- return $this->customMailOptions['senderName'] ?? '';
- }
-
- /**
- * Set reply-to email
- *
- * @param string $email
- * @return self
- */
- public function setReplyToEmail(string $email): self
- {
- $this->customMailOptions['replyToEmail'] = $email;
- return $this;
- }
-
- /**
- * Get reply-to email
- *
- * @return string
- */
- public function getReplyToEmail(): string
- {
- return $this->customMailOptions['replyToEmail'] ?? '';
- }
-
- /**
- * Set reply-to name
- *
- * @param string $name
- * @return self
- */
- public function setReplyToName(string $name): self
- {
- $this->customMailOptions['replyToName'] = $name;
- return $this;
- }
-
- /**
- * Get reply-to name
- *
- * @return string
- */
- public function getReplyToName(): string
- {
- return $this->customMailOptions['replyToName'] ?? '';
- }
-
- /**
- * Reset
- *
- * @return self
- */
- public function reset(): self
- {
- $this->project = null;
- $this->recipient = '';
- $this->name = '';
- $this->subject = '';
- $this->body = '';
- $this->variables = [];
- $this->bodyTemplate = '';
- $this->attachment = [];
- $this->customMailOptions = [];
- return $this;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'recipient' => $this->recipient,
- 'name' => $this->name,
- 'subject' => $this->subject,
- 'bodyTemplate' => $this->bodyTemplate,
- 'body' => $this->body,
- 'preview' => $this->preview,
- 'smtp' => $this->smtp,
- 'variables' => $this->variables,
- 'attachment' => $this->attachment,
- 'customMailOptions' => $this->customMailOptions,
- 'events' => Event::generateEvents($this->getEvent(), $this->getParams()),
- 'platform' => $platform,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php
new file mode 100644
index 0000000000..0c8967aff6
--- /dev/null
+++ b/src/Appwrite/Event/Message/Build.php
@@ -0,0 +1,45 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project->getArrayCopy(),
+ 'resource' => $this->resource->getArrayCopy(),
+ 'deployment' => $this->deployment->getArrayCopy(),
+ 'type' => $this->type,
+ 'template' => $this->template?->getArrayCopy(),
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ resource: new Document($data['resource'] ?? []),
+ deployment: new Document($data['deployment'] ?? []),
+ type: $data['type'] ?? '',
+ template: !empty($data['template']) ? new Document($data['template']) : null,
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Mail.php b/src/Appwrite/Event/Message/Mail.php
new file mode 100644
index 0000000000..aeeea8a616
--- /dev/null
+++ b/src/Appwrite/Event/Message/Mail.php
@@ -0,0 +1,66 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project?->getArrayCopy(),
+ 'recipient' => $this->recipient,
+ 'name' => $this->name,
+ 'subject' => $this->subject,
+ 'bodyTemplate' => $this->bodyTemplate,
+ 'body' => $this->body,
+ 'preview' => $this->preview,
+ 'smtp' => $this->smtp,
+ 'variables' => $this->variables,
+ 'attachment' => $this->attachment,
+ 'customMailOptions' => $this->customMailOptions,
+ 'events' => $this->events,
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: !empty($data['project']) ? new Document($data['project']) : null,
+ recipient: $data['recipient'] ?? '',
+ name: $data['name'] ?? '',
+ subject: $data['subject'] ?? '',
+ bodyTemplate: $data['bodyTemplate'] ?? '',
+ body: $data['body'] ?? '',
+ preview: $data['preview'] ?? '',
+ smtp: $data['smtp'] ?? [],
+ variables: $data['variables'] ?? [],
+ attachment: $data['attachment'] ?? [],
+ customMailOptions: $data['customMailOptions'] ?? [],
+ events: $data['events'] ?? [],
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Message/Messaging.php b/src/Appwrite/Event/Message/Messaging.php
new file mode 100644
index 0000000000..7f0f918217
--- /dev/null
+++ b/src/Appwrite/Event/Message/Messaging.php
@@ -0,0 +1,45 @@
+ $this->type,
+ 'project' => $this->project->getArrayCopy(),
+ 'user' => $this->user?->getArrayCopy(),
+ 'messageId' => $this->messageId,
+ 'message' => $this->message?->getArrayCopy(),
+ 'recipients' => $this->recipients,
+ 'providerType' => $this->providerType,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ type: $data['type'] ?? '',
+ project: new Document($data['project'] ?? []),
+ user: !empty($data['user']) ? new Document($data['user']) : null,
+ messageId: $data['messageId'] ?? null,
+ message: !empty($data['message']) ? new Document($data['message']) : null,
+ recipients: $data['recipients'] ?? null,
+ providerType: $data['providerType'] ?? null,
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Messaging.php b/src/Appwrite/Event/Messaging.php
deleted file mode 100644
index 9895d52ec2..0000000000
--- a/src/Appwrite/Event/Messaging.php
+++ /dev/null
@@ -1,182 +0,0 @@
-setQueue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_MESSAGING_CLASS_NAME', Event::MESSAGING_CLASS_NAME));
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `MESSAGE_SEND_TYPE_INTERNAL` or `MESSAGE_SEND_TYPE_EXTERNAL`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Sets recipient for the messaging event.
- *
- * @param string[] $recipients
- * @return self
- */
- public function setRecipients(array $recipients): self
- {
- $this->recipients = $recipients;
-
- return $this;
- }
-
- /**
- * Returns set recipient for messaging event.
- *
- * @return string[]
- */
- public function getRecipient(): array
- {
- return $this->recipients;
- }
-
- /**
- * Sets message document for the messaging event.
- *
- * @param Document $message
- * @return self
- */
- public function setMessage(Document $message): self
- {
- $this->message = $message;
-
- return $this;
- }
-
- /**
- * Returns message document for the messaging event.
- *
- * @return Document
- */
- public function getMessage(): Document
- {
- return $this->message;
- }
-
- /**
- * Sets message ID for the messaging event.
- *
- * @param string $messageId
- * @return self
- */
- public function setMessageId(string $messageId): self
- {
- $this->messageId = $messageId;
-
- return $this;
- }
-
- /**
- * Returns set message ID for the messaging event.
- *
- * @return string
- */
- public function getMessageId(): string
- {
- return $this->messageId;
- }
-
- /**
- * Sets provider type for the messaging event.
- *
- * @param string $providerType
- * @return self
- */
- public function setProviderType(string $providerType): self
- {
- $this->providerType = $providerType;
-
- return $this;
- }
-
- /**
- * Returns set provider type for the messaging event.
- *
- * @return string
- */
- public function getProviderType(): string
- {
- return $this->providerType;
- }
-
- /**
- * Sets Scheduled delivery time for the messaging event.
- *
- * @param string $scheduledAt
- * @return self
- */
- public function setScheduledAt(string $scheduledAt): self
- {
- $this->scheduledAt = $scheduledAt;
-
- return $this;
- }
-
- /**
- * Returns set Delivery Time for the messaging event.
- *
- * @return string
- */
- public function getScheduledAt(): string
- {
- return $this->scheduledAt;
- }
-
- /**
- * Prepare the payload for the event
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- return [
- 'type' => $this->type,
- 'project' => $this->project,
- 'user' => $this->user,
- 'messageId' => $this->messageId,
- 'message' => $this->message,
- 'recipients' => $this->recipients,
- 'providerType' => $this->providerType,
- ];
- }
-}
diff --git a/src/Appwrite/Event/Publisher/Build.php b/src/Appwrite/Event/Publisher/Build.php
new file mode 100644
index 0000000000..9b2a3b68a0
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Build.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Mail.php b/src/Appwrite/Event/Publisher/Mail.php
new file mode 100644
index 0000000000..16d48be044
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Mail.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Messaging.php b/src/Appwrite/Event/Publisher/Messaging.php
new file mode 100644
index 0000000000..69863566a1
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Messaging.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php
index ea54c1dc65..0cac0f4262 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -351,6 +351,10 @@ class Exception extends \Exception
public const string MIGRATION_IN_PROGRESS = 'migration_in_progress';
public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error';
public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported';
+ public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required';
+ public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found';
+ public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid';
+ public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid';
/** Realtime */
public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid';
@@ -405,6 +409,14 @@ class Exception extends \Exception
public const string TOKEN_EXPIRED = 'token_expired';
public const string TOKEN_RESOURCE_TYPE_INVALID = 'token_resource_type_invalid';
+ /** Advisor */
+ public const string INSIGHT_NOT_FOUND = 'insight_not_found';
+ public const string INSIGHT_ALREADY_EXISTS = 'insight_already_exists';
+
+ /** Reports */
+ public const string REPORT_NOT_FOUND = 'report_not_found';
+ public const string REPORT_ALREADY_EXISTS = 'report_already_exists';
+
protected string $type = '';
protected array $errors = [];
protected bool $publish;
diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php
index af88b00762..4471ab53a7 100644
--- a/src/Appwrite/GraphQL/Resolvers.php
+++ b/src/Appwrite/GraphQL/Resolvers.php
@@ -6,7 +6,6 @@ use Appwrite\GraphQL\Exception as GQLException;
use Appwrite\Promises\Swoole;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
-use Utopia\DI\Container;
use Utopia\Http\Exception;
use Utopia\Http\Http;
use Utopia\Http\Route;
@@ -53,22 +52,6 @@ class Resolvers
}
}
- /**
- * Get the current request container.
- */
- private static function getResolverContainer(Http $utopia): Container
- {
- $container = $utopia->getResource('container');
-
- if ($container instanceof Container || (\is_object($container) && \method_exists($container, 'get') && \method_exists($container, 'set'))) {
- /** @var Container $container */
- return $container;
- }
-
- /** @var callable(): Container $container */
- return $container();
- }
-
/**
* Get the request-scoped lock shared by GraphQL resolver coroutines
* for the current HTTP request.
@@ -95,9 +78,9 @@ class Resolvers
?Route $route,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $route, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -167,9 +150,9 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -203,9 +186,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
$beforeResolve = function ($payload) {
return $payload['documents'];
@@ -245,9 +228,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -282,9 +265,9 @@ class Resolvers
callable $params,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -317,9 +300,9 @@ class Resolvers
callable $url,
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $args) {
- $utopia = $utopia->getResource('utopia:graphql');
- $request = $utopia->getResource('request');
- $response = $utopia->getResource('response');
+ $utopia = $utopia->context()->get('utopia:graphql');
+ $request = $utopia->context()->get('request');
+ $response = $utopia->context()->get('response');
self::resolve(
$utopia,
@@ -374,10 +357,9 @@ class Resolvers
}
/** @var Response $resolverResponse */
- $resolverResponse = clone $utopia->getResource('response');
- $container = self::getResolverContainer($utopia);
- $container->set('request', static fn () => $request);
- $container->set('response', static fn () => $resolverResponse);
+ $resolverResponse = clone $utopia->context()->get('response');
+ $utopia->context()->set('request', static fn () => $request);
+ $utopia->context()->set('response', static fn () => $resolverResponse);
$resolverResponse->setContentType(Response::CONTENT_TYPE_NULL);
$resolverResponse->setSent(false);
diff --git a/src/Appwrite/GraphQL/Schema.php b/src/Appwrite/GraphQL/Schema.php
index 4ff96fb635..a689655e31 100644
--- a/src/Appwrite/GraphQL/Schema.php
+++ b/src/Appwrite/GraphQL/Schema.php
@@ -84,7 +84,7 @@ class Schema
protected static function api(Http $utopia, callable $complexity): array
{
Mapper::init($utopia
- ->getResource('response')
+ ->context()->get('response')
->getModels());
$queries = [];
diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php
index 55810fd74e..92c7753ad2 100644
--- a/src/Appwrite/GraphQL/Types/Mapper.php
+++ b/src/Appwrite/GraphQL/Types/Mapper.php
@@ -254,7 +254,7 @@ class Mapper
array $injections
): Type {
$validator = \is_callable($validator)
- ? \call_user_func_array($validator, $utopia->getResources($injections))
+ ? \call_user_func_array($validator, \array_map($utopia->context()->get(...), $injections))
: $validator;
$isNullable = $validator instanceof Nullable;
diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php
index 12caab3067..5015aa5465 100644
--- a/src/Appwrite/Messaging/Adapter/Realtime.php
+++ b/src/Appwrite/Messaging/Adapter/Realtime.php
@@ -776,6 +776,21 @@ class Realtime extends MessagingAdapter
$roles = [Role::team($project->getAttribute('teamId'))->toString()];
}
break;
+ case 'reports':
+ // Plain report event: `reports.{reportId}.{action}`
+ $channels[] = 'reports';
+ if (isset($parts[1])) {
+ $channels[] = 'reports.' . $parts[1];
+ }
+ // Nested insight event: `reports.{reportId}.insights.{insightId}.{action}`
+ if (isset($parts[2]) && $parts[2] === 'insights') {
+ $channels[] = 'reports.' . $parts[1] . '.insights';
+ if (isset($parts[3])) {
+ $channels[] = 'reports.' . $parts[1] . '.insights.' . $parts[3];
+ }
+ }
+ $roles = [Role::team($project->getAttribute('teamId'))->toString()];
+ break;
case 'presences':
$channels[] = 'presences';
$channels[] = 'presences.' . $parts[1];
diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php
index 08e32a9c74..77c62bce96 100644
--- a/src/Appwrite/Migration/Migration.php
+++ b/src/Appwrite/Migration/Migration.php
@@ -97,6 +97,7 @@ abstract class Migration
'1.9.2' => 'V24',
'1.9.3' => 'V24',
'1.9.4' => 'V24',
+ '1.9.5' => 'V24',
];
/**
diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php
index 13184230fa..310d59615d 100644
--- a/src/Appwrite/Platform/Appwrite.php
+++ b/src/Appwrite/Platform/Appwrite.php
@@ -3,6 +3,7 @@
namespace Appwrite\Platform;
use Appwrite\Platform\Modules\Account;
+use Appwrite\Platform\Modules\Advisor;
use Appwrite\Platform\Modules\Avatars;
use Appwrite\Platform\Modules\Console;
use Appwrite\Platform\Modules\Core;
@@ -44,5 +45,6 @@ class Appwrite extends Platform
$this->addModule(new Webhooks\Module());
$this->addModule(new Migrations\Module());
$this->addModule(new Project\Module());
+ $this->addModule(new Advisor\Module());
}
}
diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php
index 38d61b7d24..26d82adf24 100644
--- a/src/Appwrite/Platform/Installer/Server.php
+++ b/src/Appwrite/Platform/Installer/Server.php
@@ -154,7 +154,7 @@ class Server
$nativeServer = $adapter->getNativeServer();
- $container = $adapter->getContainer();
+ $container = $adapter->resources();
$container->set('installerState', fn () => $state);
$container->set('installerConfig', fn () => $config);
$container->set('installerPaths', fn () => $paths);
diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
index 7bcc78e974..285875eb35 100644
--- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
+++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php
@@ -5,8 +5,10 @@ namespace Appwrite\Platform\Modules\Account\Http\Account\MFA\Challenges;
use Appwrite\Auth\MFA\Type;
use Appwrite\Detector\Detector;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -101,8 +103,8 @@ class Create extends Action
->inject('platform')
->inject('request')
->inject('queueForEvents')
- ->inject('queueForMessaging')
- ->inject('queueForMails')
+ ->inject('publisherForMessaging')
+ ->inject('publisherForMails')
->inject('timelimit')
->inject('usage')
->inject('plan')
@@ -121,8 +123,8 @@ class Create extends Action
array $platform,
Request $request,
Event $queueForEvents,
- Messaging $queueForMessaging,
- Mail $queueForMails,
+ MessagingPublisher $publisherForMessaging,
+ MailPublisher $publisherForMails,
callable $timelimit,
Context $usage,
array $plan,
@@ -180,16 +182,18 @@ class Create extends Action
$message = $message->render();
$phone = $user->getAttribute('phone');
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage(new Document([
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: new Document([
'$id' => $challenge->getId(),
'data' => [
'content' => $code,
],
- ]))
- ->setRecipients([$phone])
- ->setProviderType(MESSAGE_TYPE_SMS);
+ ]),
+ recipients: [$phone],
+ providerType: MESSAGE_TYPE_SMS,
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
@@ -252,6 +256,7 @@ class Create extends Action
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (!empty($smtp['senderEmail'])) {
@@ -269,13 +274,6 @@ class Create extends Action
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (!empty($customTemplate)) {
if (!empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -296,11 +294,17 @@ class Create extends Action
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -327,20 +331,18 @@ class Create extends Action
]);
}
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setBodyTemplate($bodyTemplate)
- ->appendVariables($emailVariables)
- ->setRecipient($user->getAttribute('email'));
-
- // since this is console project, set email sender name!
- if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) {
- $queueForMails->setSenderName($platform['emailSenderName']);
- }
-
- $queueForMails->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ subject: $subject,
+ bodyTemplate: $bodyTemplate,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ customMailOptions: $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE ? ['senderName' => $platform['emailSenderName']] : [],
+ platform: $platform,
+ ));
break;
}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
new file mode 100644
index 0000000000..31d578a991
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Enums/InsightCTAMethod.php
@@ -0,0 +1,8 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights/:insightId')
+ ->desc('Get insight')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'getInsight',
+ description: '/docs/references/advisor/get-insight.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('insightId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Insight ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ string $insightId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery â we only need ownership metadata.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insight = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $insight->isEmpty()
+ || $insight->getAttribute('projectInternalId') !== $project->getSequence()
+ || $insight->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::INSIGHT_NOT_FOUND);
+ }
+
+ $response->dynamic($insight, Response::MODEL_INSIGHT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
new file mode 100644
index 0000000000..64d3676c08
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Insights/XList.php
@@ -0,0 +1,126 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId/insights')
+ ->desc('List insights')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'insights.read')
+ ->label('resourceType', RESOURCE_TYPE_INSIGHTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'insights',
+ name: 'listInsights',
+ description: '/docs/references/advisor/list-insights.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_INSIGHT_LIST,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Parent report ID.', false, ['dbForPlatform'])
+ ->param('queries', [], new Insights(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Insights::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ // Skip the insights subquery â we're about to fetch a filtered, paginated slice ourselves.
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
+ $queries[] = Query::equal('reportInternalId', [$report->getSequence()]);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $insightId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->getDocument('insights', $insightId);
+
+ if (
+ $cursorDocument->isEmpty()
+ || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()
+ || $cursorDocument->getAttribute('reportInternalId') !== $report->getSequence()
+ ) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Insight '{$insightId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $insights = $dbForPlatform->find('insights', $queries);
+ $total = $includeTotal ? $dbForPlatform->count('insights', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
+ $response->dynamic(new Document([
+ 'insights' => $insights,
+ 'total' => $total,
+ ]), Response::MODEL_INSIGHT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
new file mode 100644
index 0000000000..6b1dfba31b
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Delete.php
@@ -0,0 +1,97 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Delete report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.write')
+ ->label('event', 'reports.[reportId].delete')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('audits.event', 'report.delete')
+ ->label('audits.resource', 'report/{request.reportId}')
+ ->label('abuse-key', 'projectId:{projectId},userId:{userId}')
+ ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT)
+ ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'deleteReport',
+ description: '/docs/references/advisor/delete-report.md',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_NOCONTENT,
+ model: Response::MODEL_NONE,
+ ),
+ ],
+ contentType: ContentType::NONE
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->inject('queueForDeletes')
+ ->inject('queueForEvents')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform,
+ DeleteEvent $queueForDeletes,
+ Event $queueForEvents
+ ): void {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ if (!$dbForPlatform->deleteDocument('reports', $report->getId())) {
+ throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove report from DB');
+ }
+
+ $queueForDeletes
+ ->setType(DELETE_TYPE_REPORT)
+ ->setDocument($report);
+
+ $queueForEvents
+ ->setParam('reportId', $report->getId())
+ ->setPayload($response->output($report, Response::MODEL_REPORT));
+
+ $response->noContent();
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
new file mode 100644
index 0000000000..78885a7c5d
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/Get.php
@@ -0,0 +1,80 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports/:reportId')
+ ->desc('Get report')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'getReport',
+ description: '/docs/references/advisor/get-report.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT,
+ ),
+ ]
+ ))
+ ->param('reportId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Report ID.', false, ['dbForPlatform'])
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ string $reportId,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ $report = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($report->isEmpty() || $report->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::REPORT_NOT_FOUND);
+ }
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', [$report->getSequence()]),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $report->setAttribute('insights', $insights);
+
+ $response->dynamic($report, Response::MODEL_REPORT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
new file mode 100644
index 0000000000..c5debb7f68
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Http/Reports/XList.php
@@ -0,0 +1,133 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/reports')
+ ->desc('List reports')
+ ->groups(['api', 'advisor'])
+ ->label('scope', 'reports.read')
+ ->label('resourceType', RESOURCE_TYPE_REPORTS)
+ ->label('sdk', new Method(
+ namespace: 'advisor',
+ group: 'reports',
+ name: 'listReports',
+ description: '/docs/references/advisor/list-reports.md',
+ auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: Response::MODEL_REPORT_LIST,
+ ),
+ ]
+ ))
+ ->param('queries', [], new Reports(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Reports::ALLOWED_ATTRIBUTES), true)
+ ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
+ ->inject('response')
+ ->inject('project')
+ ->inject('dbForPlatform')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ array $queries,
+ bool $includeTotal,
+ Response $response,
+ Document $project,
+ Database $dbForPlatform
+ ) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
+ $queries[] = Query::equal('projectInternalId', [$project->getSequence()]);
+
+ $cursor = Query::getCursorQueries($queries, false);
+ $cursor = \reset($cursor);
+
+ if ($cursor !== false) {
+ $validator = new Cursor();
+ if (!$validator->isValid($cursor)) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription());
+ }
+
+ $reportId = $cursor->getValue();
+ $cursorDocument = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->getDocument('reports', $reportId),
+ ['subQueryReportInsights'],
+ );
+
+ if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence()) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Report '{$reportId}' for the 'cursor' value not found.");
+ }
+
+ $cursor->setValue($cursorDocument);
+ }
+
+ $filterQueries = Query::groupByType($queries)['filters'];
+
+ try {
+ $reports = $dbForPlatform->skipFilters(
+ fn () => $dbForPlatform->find('reports', $queries),
+ ['subQueryReportInsights'],
+ );
+ $total = $includeTotal ? $dbForPlatform->count('reports', $filterQueries, APP_LIMIT_COUNT) : 0;
+ } catch (OrderException $e) {
+ throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
+ }
+
+ if (!empty($reports)) {
+ $reportSequences = \array_map(fn (Document $r) => $r->getSequence(), $reports);
+
+ $insights = $dbForPlatform->find('insights', [
+ Query::equal('projectInternalId', [$project->getSequence()]),
+ Query::equal('reportInternalId', $reportSequences),
+ Query::limit(APP_LIMIT_SUBQUERY),
+ ]);
+
+ $insightsByReport = [];
+ foreach ($insights as $insight) {
+ $insightsByReport[$insight->getAttribute('reportInternalId')][] = $insight;
+ }
+
+ foreach ($reports as $report) {
+ $report->setAttribute('insights', $insightsByReport[$report->getSequence()] ?? []);
+ }
+ }
+
+ $response->dynamic(new Document([
+ 'reports' => $reports,
+ 'total' => $total,
+ ]), Response::MODEL_REPORT_LIST);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Module.php b/src/Appwrite/Platform/Modules/Advisor/Module.php
new file mode 100644
index 0000000000..b28a2421c2
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Module.php
@@ -0,0 +1,14 @@
+addService('http', new Http());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Advisor/Services/Http.php b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
new file mode 100644
index 0000000000..2558b00247
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Advisor/Services/Http.php
@@ -0,0 +1,25 @@
+type = Service::TYPE_HTTP;
+
+ $this->addAction(GetReport::getName(), new GetReport());
+ $this->addAction(ListReports::getName(), new ListReports());
+ $this->addAction(DeleteReport::getName(), new DeleteReport());
+
+ $this->addAction(GetInsight::getName(), new GetInsight());
+ $this->addAction(ListInsights::getName(), new ListInsights());
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php
index 85dfec3cfd..b0efac3829 100644
--- a/src/Appwrite/Platform/Modules/Compute/Base.php
+++ b/src/Appwrite/Platform/Modules/Compute/Base.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Compute;
-use Appwrite\Event\Build;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Platform\Action;
@@ -57,7 +58,7 @@ class Base extends Action
return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT;
}
- public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$entrypoint = $function->getAttribute('entrypoint', '');
@@ -150,16 +151,19 @@ class Base extends Action
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
- public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$providerInstallationId = $installation->getAttribute('providerInstallationId', '');
@@ -358,11 +362,14 @@ class Base extends Action
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
index 757edc0484..57c465faef 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -87,9 +88,10 @@ class Create extends Action
->inject('project')
->inject('deviceForFunctions')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -106,9 +108,10 @@ class Create extends Action
Document $project,
Device $deviceForFunctions,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$activate = \strval($activate) === 'true' || \strval($activate) === '1';
@@ -272,10 +275,13 @@ class Create extends Action
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
index 9884b12dba..76070c8bf5 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -61,8 +62,10 @@ class Create extends Action
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForFunctions')
+ ->inject('project')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -73,8 +76,10 @@ class Create extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
- Build $queueForBuilds,
- Device $deviceForFunctions
+ BuildPublisher $publisherForBuilds,
+ Device $deviceForFunctions,
+ Document $project,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +132,13 @@ class Create extends Action
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
index 53af82e701..f18543c60e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -76,9 +77,10 @@ class Create extends Base
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('project')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -96,9 +98,10 @@ class Create extends Base
Database $dbForPlatform,
Event $queueForEvents,
Document $project,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +130,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
referenceType: $type,
reference: $reference
);
@@ -184,11 +188,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
index 587c09beba..a74fc12593 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -70,8 +70,9 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -86,8 +87,9 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
+ array $platform,
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -105,10 +107,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
reference: $reference,
referenceType: $type
);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
index 7b294f3f90..00a91141fb 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -2,9 +2,10 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Event\Webhook;
@@ -115,7 +116,7 @@ class Create extends Base
->inject('timelimit')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForRealtime')
->inject('queueForWebhooks')
->inject('queueForFunctions')
@@ -157,7 +158,7 @@ class Create extends Base
callable $timelimit,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Realtime $queueForRealtime,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -326,10 +327,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: true,
+ platform: $platform,
reference: $providerBranch,
referenceType: 'branch'
);
@@ -367,11 +369,14 @@ class Create extends Base
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
}
$functionsDomain = $platform['functionsDomain'];
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
index 7d6572d336..e8713a179d 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
@@ -105,11 +105,12 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -139,11 +140,12 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
Executor $executor,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
// TODO: If only branch changes, re-deploy
$function = $dbForProject->getDocument('functions', $functionId);
@@ -281,11 +283,33 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
// Inform scheduler if function is still active
- $schedule = $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId'));
+ $schedule = $authorization->skip(fn () => $dbForPlatform->getDocument('schedules', $function->getAttribute('scheduleId')));
+
+ // Re-create schedule if missing
+ if ($schedule->isEmpty()) {
+ $schedule = $authorization->skip(
+ fn () => $dbForPlatform->createDocument('schedules', new Document([
+ 'region' => $project->getAttribute('region'),
+ 'resourceType' => SCHEDULE_RESOURCE_TYPE_FUNCTION,
+ 'resourceId' => $function->getId(),
+ 'resourceInternalId' => $function->getSequence(),
+ 'resourceUpdatedAt' => DateTime::now(),
+ 'projectId' => $project->getId(),
+ 'schedule' => $function->getAttribute('schedule'),
+ 'active' => false,
+ ]))
+ );
+
+ $function = $dbForProject->updateDocument('functions', $function->getId(), new Document([
+ 'scheduleId' => $schedule->getId(),
+ 'scheduleInternalId' => $schedule->getSequence(),
+ ]));
+ }
+
$schedule
->setAttribute('resourceUpdatedAt', DateTime::now())
->setAttribute('schedule', $function->getAttribute('schedule'))
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
index 7d1cdc4980..c766f73929 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
@@ -109,9 +109,7 @@ class Screenshots extends Action
throw new \Exception("Rule for deployment not found");
}
- $client = new FetchClient();
- $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000);
- $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+ $timeout = \intval($site->getAttribute('timeout', '15')) * 1000;
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
@@ -162,8 +160,8 @@ class Screenshots extends Action
]);
$screenshotError = null;
- $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) {
- return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) {
+ $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) {
+ return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) {
try {
$config = $configs[$key];
@@ -179,6 +177,10 @@ class Screenshots extends Action
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
+ $client = new FetchClient();
+ $client->setTimeout($timeout);
+ $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
index 8ae7c8687a..98e65e37e5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Build $queueForBuilds, Response $response): void
+ public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForBuilds->getSize();
+ $size = $publisherForBuilds->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
index 7602de45d3..70d7713280 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
@@ -2,15 +2,15 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed;
-use Appwrite\Event\Build;
use Appwrite\Event\Database;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Event\Publisher\Screenshot;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
@@ -77,14 +77,14 @@ class Get extends Base
->inject('queueForDatabase')
->inject('queueForDeletes')
->inject('publisherForAudits')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForFunctions')
->inject('publisherForStatsResources')
->inject('publisherForUsage')
->inject('queueForWebhooks')
->inject('publisherForCertificates')
- ->inject('queueForBuilds')
- ->inject('queueForMessaging')
+ ->inject('publisherForBuilds')
+ ->inject('publisherForMessaging')
->inject('publisherForMigrations')
->inject('publisherForScreenshots')
->callback($this->action(...));
@@ -97,14 +97,14 @@ class Get extends Base
Database $queueForDatabase,
Delete $queueForDeletes,
Audit $publisherForAudits,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Func $queueForFunctions,
StatsResourcesPublisher $publisherForStatsResources,
UsagePublisher $publisherForUsage,
Webhook $queueForWebhooks,
Certificate $publisherForCertificates,
- Build $queueForBuilds,
- Messaging $queueForMessaging,
+ BuildPublisher $publisherForBuilds,
+ MessagingPublisher $publisherForMessaging,
MigrationPublisher $publisherForMigrations,
Screenshot $publisherForScreenshots,
): void {
@@ -114,15 +114,15 @@ class Get extends Base
System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME) => $queueForDatabase,
System::getEnv('_APP_DELETE_QUEUE_NAME', Event::DELETE_QUEUE_NAME) => $queueForDeletes,
System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME) => $publisherForAudits,
- System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $queueForMails,
+ System::getEnv('_APP_MAILS_QUEUE_NAME', Event::MAILS_QUEUE_NAME) => $publisherForMails,
System::getEnv('_APP_FUNCTIONS_QUEUE_NAME', Event::FUNCTIONS_QUEUE_NAME) => $queueForFunctions,
System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME) => $publisherForStatsResources,
System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage,
System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks,
System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates,
- System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds,
+ System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds,
System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots,
- System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
+ System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $publisherForMessaging,
System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations,
default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name),
};
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
index 3b9c06b5f9..2dd36e8111 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Mails/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Mails;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Mail $queueForMails, Response $response): void
+ public function action(int|string $threshold, MailPublisher $publisherForMails, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMails->getSize();
+ $size = $publisherForMails->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
index db2d7d7172..a2a829b1d5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Messaging/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Messaging;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForMessaging')
+ ->inject('publisherForMessaging')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Messaging $queueForMessaging, Response $response): void
+ public function action(int|string $threshold, MessagingPublisher $publisherForMessaging, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForMessaging->getSize();
+ $size = $publisherForMessaging->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
index 006ab3ae90..fa700877a1 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
@@ -13,6 +13,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -57,6 +58,7 @@ class Create extends Action
->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
->param('apiKey', '', new Text(512), 'Source API Key')
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('project')
@@ -71,6 +73,7 @@ class Create extends Action
string $endpoint,
string $projectId,
string $apiKey,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Document $project,
@@ -93,6 +96,9 @@ class Create extends Action
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
+ 'options' => [
+ 'onDuplicate' => $onDuplicate,
+ ],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
index 5cc21241c3..4b47ed7d58 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
@@ -20,6 +20,7 @@ use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\CSV;
@@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
+use Utopia\Validator\WhiteList;
class Create extends Action
{
@@ -67,6 +69,7 @@ class Create extends Action
->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
@@ -85,6 +88,7 @@ class Create extends Action
string $fileId,
string $resourceId,
bool $internalFile,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
@@ -183,6 +187,7 @@ class Create extends Action
'options' => [
'path' => $newPath,
'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
],
]));
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
index 55081b2645..c5d936711e 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
@@ -20,6 +20,7 @@ use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\JSON as JSONSource;
@@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
+use Utopia\Validator\WhiteList;
class Create extends Action
{
@@ -66,6 +68,7 @@ class Create extends Action
->param('fileId', '', new UID(), 'File ID.')
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
@@ -84,6 +87,7 @@ class Create extends Action
string $fileId,
string $resourceId,
bool $internalFile,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
@@ -183,6 +187,7 @@ class Create extends Action
'options' => [
'path' => $newPath,
'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
],
]));
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
index 0a60e4ce4d..4b26557ca9 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php
@@ -33,7 +33,6 @@ class Delete extends Action
->desc('Delete project')
->groups(['api', 'project'])
->label('scope', 'project.write')
- ->label('event', 'project.delete')
->label('audits.event', 'project.delete')
->label('audits.resource', 'project/{project.$id}')
->label('sdk', new Method(
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
new file mode 100644
index 0000000000..197d82ef58
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Get.php
@@ -0,0 +1,64 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
+ ->setHttpPath('/v1/project')
+ ->httpAlias('/v1/projects/:projectId')
+ ->desc('Get project')
+ ->groups(['api', 'project'])
+ ->label('scope', 'project.read')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: null,
+ name: 'get',
+ description: <<inject('response')
+ ->inject('project')
+ ->callback($this->action(...));
+ }
+
+ public function action(
+ Response $response,
+ Document $project,
+ ) {
+ if ($project->isEmpty()) {
+ throw new Exception(Exception::PROJECT_NOT_FOUND);
+ }
+
+ $response->dynamic($project, Response::MODEL_PROJECT);
+ }
+}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
index 9b985f4aed..2a061d09ce 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php
@@ -3,8 +3,22 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google;
use Appwrite\Auth\OAuth2\Google;
+use Appwrite\Event\Event as QueueEvent;
+use Appwrite\Extend\Exception;
+use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base;
+use Appwrite\SDK\AuthType;
+use Appwrite\SDK\Method;
+use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
+use Utopia\Database\Database;
+use Utopia\Database\Document;
+use Utopia\Database\Validator\Authorization;
+use Utopia\Validator\ArrayList;
+use Utopia\Validator\Boolean;
+use Utopia\Validator\Nullable;
+use Utopia\Validator\Text;
+use Utopia\Validator\WhiteList;
class Update extends Base
{
@@ -52,4 +66,118 @@ class Update extends Base
{
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
+
+ public static function getParameters(): array
+ {
+ return \array_merge(parent::getParameters(), [
+ [
+ '$id' => 'prompt',
+ 'name' => 'Prompt',
+ 'example' => '["consent"]',
+ 'hint' => '',
+ ],
+ ]);
+ }
+
+ public function __construct()
+ {
+ $providerId = static::getProviderId();
+ $providerLabel = static::getProviderLabel();
+
+ $this
+ ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
+ ->setHttpPath('/v1/project/oauth2/' . $providerId)
+ ->desc('Update project OAuth2 ' . $providerLabel)
+ ->groups(['api', 'project'])
+ ->label('scope', 'oauth2.write')
+ ->label('event', 'oauth2.[providerId].update')
+ ->label('audits.event', 'project.oauth2.[providerId].update')
+ ->label('audits.resource', 'project.oauth2/{response.$id}')
+ ->label('sdk', new Method(
+ namespace: 'project',
+ group: 'oauth2',
+ name: static::getProviderSDKMethod(),
+ description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.',
+ auth: [AuthType::ADMIN, AuthType::KEY],
+ responses: [
+ new SDKResponse(
+ code: Response::STATUS_CODE_OK,
+ model: static::getResponseModel(),
+ )
+ ],
+ ))
+ ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
+ ->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
+ ->param('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', optional: true)
+ ->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
+ ->inject('response')
+ ->inject('dbForPlatform')
+ ->inject('project')
+ ->inject('authorization')
+ ->inject('queueForEvents')
+ ->callback($this->handle(...));
+ }
+
+ public function buildReadResponse(Document $project): Document
+ {
+ $providerId = static::getProviderId();
+ $oAuthProviders = $project->getAttribute('oAuthProviders', []);
+ $decoded = $this->decodeStoredSecret($project);
+
+ return new Document([
+ '$id' => $providerId,
+ 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
+ static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
+ static::getClientSecretParamName() => '',
+ 'prompt' => $decoded['prompt'] ?? ['consent'],
+ ]);
+ }
+
+ /**
+ * Custom callback used instead of the parent's `action()` because Google
+ * takes an additional optional `prompt` parameter. The method is named
+ * differently to avoid an LSP-incompatible override of Base::action().
+ */
+ public function handle(
+ ?string $clientId,
+ ?string $clientSecret,
+ ?array $prompt,
+ ?bool $enabled,
+ Response $response,
+ Database $dbForPlatform,
+ Document $project,
+ Authorization $authorization,
+ QueueEvent $queueForEvents
+ ): void {
+ $providerId = static::getProviderId();
+ $queueForEvents->setParam('providerId', $providerId);
+
+ if ($prompt !== null) {
+ if (empty($prompt)) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.');
+ }
+
+ if (\in_array('none', $prompt) && \count($prompt) > 1) {
+ throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.');
+ }
+ }
+
+ $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
+ $existing = $this->decodeStoredSecret($project);
+
+ // Backwards compatibility: secrets stored before the prompt feature
+ // were saved as plain strings. Treat the raw value as clientSecret.
+ if (!empty($storedRaw) && empty($existing)) {
+ $existing = ['clientSecret' => $storedRaw];
+ }
+
+ $encodedSecret = \json_encode([
+ 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
+ 'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']),
+ ]);
+
+ $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
+
+ $response->dynamic($this->buildReadResponse($project), static::getResponseModel());
+ }
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
index 7095c2d2d0..8c87a41475 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Extend\Exception as Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
@@ -67,7 +68,7 @@ class Create extends Action
->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility
->inject('response')
->inject('project')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('plan')
->callback($this->action(...));
}
@@ -87,7 +88,7 @@ class Create extends Action
string $paramSecure, // Backwards compatibility
Response $response,
Document $project,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
array $plan
): void {
// Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config.
@@ -153,23 +154,24 @@ class Create extends Action
->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL);
foreach ($emails as $email) {
- $queueForMails
- ->setSmtpHost($host)
- ->setSmtpPort($port)
- ->setSmtpUsername($username)
- ->setSmtpPassword($password)
- ->setSmtpSecure($secure)
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName)
- ->setRecipient($email)
- ->setName('')
- ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl')
- ->setBody($template->render())
- ->setVariables([])
- ->setSubject($subject)
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $email,
+ subject: $subject,
+ bodyTemplate: APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl',
+ body: $template->render(),
+ smtp: [
+ 'host' => $host,
+ 'port' => $port,
+ 'username' => $username,
+ 'password' => $password,
+ 'secure' => $secure,
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ],
+ ));
}
$response->noContent();
diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php
index 609de96530..d36d0e9005 100644
--- a/src/Appwrite/Platform/Modules/Project/Services/Http.php
+++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
+use Appwrite\Platform\Modules\Project\Http\Project\Get as GetProject;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey;
@@ -110,6 +111,7 @@ class Http extends Service
// Project
$this->addAction(DeleteProject::getName(), new DeleteProject());
+ $this->addAction(GetProject::getName(), new GetProject());
$this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels());
$this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol());
$this->addAction(UpdateProjectService::getName(), new UpdateProjectService());
diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
index 363c99dc1f..d2c92fc65c 100644
--- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
+++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php
@@ -28,7 +28,6 @@ use Utopia\Pools\Group;
use Utopia\System\System;
use Utopia\Validator;
use Utopia\Validator\Text;
-use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
class Create extends Action
@@ -72,15 +71,6 @@ class Create extends Action
->param('name', null, new Text(128), 'Project name. Max length: 128 chars.')
->param('teamId', '', new UID(), 'Team unique ID.')
->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true)
- ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true)
- ->param('logo', '', new Text(1024), 'Project logo.', true)
- ->param('url', '', new URL(), 'Project URL.', true)
- ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true)
- ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true)
- ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true)
- ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true)
- ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true)
- ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true)
->inject('request')
->inject('response')
->inject('dbForPlatform')
@@ -90,7 +80,7 @@ class Create extends Action
->callback($this->action(...));
}
- public function action(string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
+ public function action(string $projectId, string $name, string $teamId, string $region, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks)
{
$team = $dbForPlatform->getDocument('teams', $teamId);
@@ -175,16 +165,7 @@ class Create extends Action
'teamInternalId' => $team->getSequence(),
'teamId' => $team->getId(),
'region' => $region,
- 'description' => $description,
- 'logo' => $logo,
- 'url' => $url,
'version' => APP_VERSION_STABLE,
- 'legalName' => $legalName,
- 'legalCountry' => $legalCountry,
- 'legalState' => $legalState,
- 'legalCity' => $legalCity,
- 'legalAddress' => $legalAddress,
- 'legalTaxId' => ID::custom($legalTaxId),
'services' => new \stdClass(),
'platforms' => null,
'oAuthProviders' => [],
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
index 71ea5ceb2f..63ed776709 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -85,7 +86,7 @@ class Create extends Action
->inject('queueForEvents')
->inject('deviceForSites')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
->inject('platform')
@@ -107,7 +108,7 @@ class Create extends Action
Event $queueForEvents,
Device $deviceForSites,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
Authorization $authorization,
array $platform,
@@ -315,10 +316,13 @@ class Create extends Action
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
index 546549604b..b3619c6017 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -63,7 +64,7 @@ class Create extends Action
->inject('dbForProject')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForSites')
->inject('authorization')
->inject('platform')
@@ -79,7 +80,7 @@ class Create extends Action
Database $dbForProject,
Database $dbForPlatform,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Device $deviceForSites,
Authorization $authorization,
array $platform
@@ -177,10 +178,13 @@ class Create extends Action
]))
);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
index f648c57a83..29854d473b 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -77,7 +78,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -98,7 +99,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -130,7 +131,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
@@ -223,11 +224,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
index 4351dd8dd9..d34b8c4055 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -71,7 +71,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -89,7 +89,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -111,7 +111,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
index 3c0d090b7b..2aee03265e 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Sites;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Compute\Validator\Specification;
@@ -99,10 +99,11 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -133,10 +134,11 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
- Executor $executor
+ Executor $executor,
+ array $platform
) {
if (!empty($adapter)) {
$configFramework = Config::getParam('frameworks')[$framework] ?? [];
@@ -279,7 +281,7 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
$queueForEvents->setParam('siteId', $site->getId());
diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
new file mode 100644
index 0000000000..ef2ace34ff
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
@@ -0,0 +1,20 @@
+inject('project')
->inject('authorization')
->inject('user')
+ ->inject('cacheControlForStorage')
->callback($this->action(...));
}
@@ -120,7 +123,8 @@ class Get extends Action
Device $deviceForLocal,
Document $project,
Authorization $authorization,
- User $user
+ User $user,
+ callable $cacheControlForStorage
) {
if (!\extension_loaded('imagick')) {
@@ -241,28 +245,43 @@ class Get extends Action
throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage());
}
- $image->crop((int) $width, (int) $height, $gravity);
+ if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) {
+ Span::add('storage.transform.crop.width', $width);
+ Span::add('storage.transform.crop.height', $height);
+ Span::add('storage.transform.crop.gravity', $gravity);
+ $image->crop($width, $height, $gravity);
+ }
- if (!empty($opacity)) {
+ if ($opacity !== 1.0) {
+ Span::add('storage.transform.opacity', $opacity);
$image->setOpacity($opacity);
}
if (!empty($background)) {
+ Span::add('storage.transform.background', $background);
$image->setBackground('#' . $background);
}
- if (!empty($borderWidth)) {
+ if ($borderWidth > 0) {
+ Span::add('storage.transform.border.width', $borderWidth);
+ Span::add('storage.transform.border.color', $borderColor);
$image->setBorder($borderWidth, '#' . $borderColor);
}
- if (!empty($borderRadius)) {
+ if ($borderRadius > 0) {
+ Span::add('storage.transform.borderRadius', $borderRadius);
$image->setBorderRadius($borderRadius);
}
- if (!empty($rotation)) {
+ if ($rotation !== 0) {
+ Span::add('storage.transform.rotation', $rotation);
$image->setRotation(($rotation + 360) % 360);
}
+ if ($quality !== -1) {
+ Span::add('storage.transform.quality', $quality);
+ }
+
$data = $image->output($output, $quality);
$renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime;
@@ -294,8 +313,20 @@ class Get extends Action
}
}
+ $maxAge = 2592000; // 30 days
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_ACTION,
+ user: $user,
+ maxAge: $maxAge,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ ));
+
$response
- ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
+ ->addHeader('Cache-Control', $cacheControl)
->setContentType($contentType)
->file($data);
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
index e174029031..5500a56cbc 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -4,8 +4,10 @@ namespace Appwrite\Platform\Modules\Teams\Http\Memberships;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event;
-use Appwrite\Event\Mail;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Message\Mail as MailMessage;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
@@ -87,18 +89,19 @@ class Create extends Action
->inject('dbForProject')
->inject('authorization')
->inject('locale')
- ->inject('queueForMails')
- ->inject('queueForMessaging')
+ ->inject('publisherForMails')
+ ->inject('publisherForMessaging')
->inject('queueForEvents')
->inject('timelimit')
->inject('usage')
->inject('plan')
+ ->inject('platform')
->inject('proofForPassword')
->inject('proofForToken')
->callback($this->action(...));
}
- public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken)
+ public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, MailPublisher $publisherForMails, MessagingPublisher $publisherForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, array $platform, Password $proofForPassword, Token $proofForToken)
{
$isAppUser = $user->isApp($authorization->getRoles());
$isPrivilegedUser = $user->isPrivileged($authorization->getRoles());
@@ -189,15 +192,15 @@ class Create extends Action
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -345,6 +348,7 @@ class Create extends Action
$senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server');
$replyToEmail = '';
$replyToName = '';
+ $smtpConfig = [];
if ($smtpEnabled) {
if (! empty($smtp['senderEmail'])) {
@@ -362,13 +366,6 @@ class Create extends Action
$replyToName = $smtp['replyToName'];
}
- $queueForMails
- ->setSmtpHost($smtp['host'] ?? '')
- ->setSmtpPort($smtp['port'] ?? '')
- ->setSmtpUsername($smtp['username'] ?? '')
- ->setSmtpPassword($smtp['password'] ?? '')
- ->setSmtpSecure($smtp['secure'] ?? '');
-
if (! empty($customTemplate)) {
if (! empty($customTemplate['senderEmail'])) {
$senderEmail = $customTemplate['senderEmail'];
@@ -389,11 +386,17 @@ class Create extends Action
$subject = $customTemplate['subject'] ?? $subject;
}
- $queueForMails
- ->setSmtpReplyToEmail($replyToEmail)
- ->setSmtpReplyToName($replyToName)
- ->setSmtpSenderEmail($senderEmail)
- ->setSmtpSenderName($senderName);
+ $smtpConfig = [
+ 'host' => $smtp['host'] ?? '',
+ 'port' => $smtp['port'] ?? '',
+ 'username' => $smtp['username'] ?? '',
+ 'password' => $smtp['password'] ?? '',
+ 'secure' => $smtp['secure'] ?? '',
+ 'replyToEmail' => $replyToEmail,
+ 'replyToName' => $replyToName,
+ 'senderEmail' => $senderEmail,
+ 'senderName' => $senderName,
+ ];
}
$emailVariables = [
@@ -406,14 +409,17 @@ class Create extends Action
'project' => $projectName,
];
- $queueForMails
- ->setSubject($subject)
- ->setBody($body)
- ->setPreview($preview)
- ->setRecipient($invitee->getAttribute('email'))
- ->setName($invitee->getAttribute('name', ''))
- ->appendVariables($emailVariables)
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $invitee->getAttribute('email'),
+ name: $invitee->getAttribute('name', ''),
+ subject: $subject,
+ body: $body,
+ preview: $preview,
+ smtp: $smtpConfig,
+ variables: $emailVariables,
+ platform: $platform,
+ ));
} elseif (! empty($phone)) {
if (empty(System::getEnv('_APP_SMS_PROVIDER'))) {
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
@@ -431,11 +437,13 @@ class Create extends Action
],
]);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_INTERNAL)
- ->setMessage($messageDoc)
- ->setRecipients([$phone])
- ->setProviderType('SMS');
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_INTERNAL,
+ project: $project,
+ message: $messageDoc,
+ recipients: [$phone],
+ providerType: 'SMS',
+ ));
$helper = PhoneNumberUtil::getInstance();
try {
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
index 8b320535e9..a40d7fc6b9 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -60,7 +60,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -75,7 +75,7 @@ class Update extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
@@ -130,7 +130,7 @@ class Update extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
index 33d7e984fb..8bc090bb03 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
@@ -43,7 +44,7 @@ trait Deployment
bool $external,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -528,14 +529,16 @@ trait Deployment
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setQueue($queueName)
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($resource)
- ->setDeployment($deployment)
- ->setProject($project); // set the project because it won't be set for git deployments
-
- $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
+ $publisherForBuilds->enqueue(
+ new BuildMessage(
+ project: $project,
+ resource: $resource,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ),
+ new \Utopia\Queue\Queue($queueName)
+ );
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
@@ -545,8 +548,6 @@ trait Deployment
}
}
- $queueForBuilds->reset(); // prevent shutdown hook from triggering again
-
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
index e3dbcfa0e9..0b81504309 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -41,7 +41,7 @@ class Create extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -53,7 +53,7 @@ class Create extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$this->preprocessEvent($request);
@@ -78,8 +78,8 @@ class Create extends Action
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
- $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
- $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
default => null,
};
@@ -129,7 +129,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -164,7 +164,7 @@ class Create extends Action
// Create new deployment only on push (not committed by us) and not when branch is deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) {
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
}
}
@@ -175,7 +175,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -216,7 +216,7 @@ class Create extends Action
Query::orderDesc('$createdAt')
]));
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
index 8ead94b7cb..fda462159f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
@@ -7,9 +7,12 @@ use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Branches;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -49,6 +52,8 @@ class XList extends Action
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('providerRepositoryId', '', new Text(256), 'Repository Id')
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true)
->inject('gitHub')
->inject('response')
->inject('dbForPlatform')
@@ -58,10 +63,18 @@ class XList extends Action
public function action(
string $installationId,
string $providerRepositoryId,
+ string $search,
+ array $queries,
GitHub $github,
Response $response,
Database $dbForPlatform
) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
@@ -85,11 +98,48 @@ class XList extends Action
$branches = $github->listBranches($owner, $repositoryName);
+ if (!empty($search)) {
+ $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false));
+ }
+
+ $total = \count($branches);
+ [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ ] = Query::groupByType($queries);
+ $cursorQuery = \current(Query::getCursorQueries($queries, false));
+
+ $limit ??= APP_LIMIT_LIST_DEFAULT;
+ $offset ??= 0;
+
+ if ($cursorQuery instanceof Query) {
+ $cursor = $cursorQuery->getValue();
+ $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER
+ ? Database::CURSOR_AFTER
+ : Database::CURSOR_BEFORE;
+
+ $cursorIndex = \array_search($cursor, $branches, true);
+ if ($cursorIndex === false) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found.");
+ }
+
+ $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0;
+
+ if ($cursorDirection === Database::CURSOR_BEFORE) {
+ $start = \max(0, $cursorIndex - $limit);
+ $branches = \array_slice($branches, $start, $cursorIndex - $start);
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+
$response->dynamic(new Document([
'branches' => \array_map(function ($branch) {
return new Document(['name' => $branch]);
}, $branches),
- 'total' => \count($branches),
+ 'total' => $total,
]), Response::MODEL_BRANCH_LIST);
}
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index b1580f0e68..5d52d905f6 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
case 'claude-plugin':
$config = new ClaudePlugin();
break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
@@ -489,7 +493,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setGitRepo($language['gitUrl'])
->setGitRepoName($language['gitRepoName'])
->setGitUserName($language['gitUserName'])
- ->setLogo($cover)
+ ->setCoverImage($cover)
->setURL('https://appwrite.io')
->setShareText('Appwrite is a backend as a service for building web or mobile apps')
->setShareURL('http://appwrite.io')
diff --git a/src/Appwrite/Platform/Tasks/ScheduleMessages.php b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
index 57f6dd8002..634fb26dc2 100644
--- a/src/Appwrite/Platform/Tasks/ScheduleMessages.php
+++ b/src/Appwrite/Platform/Tasks/ScheduleMessages.php
@@ -2,14 +2,20 @@
namespace Appwrite\Platform\Tasks;
-use Appwrite\Event\Messaging;
+use Appwrite\Event\Event;
+use Appwrite\Event\Message\Messaging as MessagingMessage;
+use Appwrite\Event\Publisher\Messaging as MessagingPublisher;
use Utopia\Database\Database;
+use Utopia\Queue\Queue;
+use Utopia\System\System;
class ScheduleMessages extends ScheduleBase
{
public const UPDATE_TIMER = 3; // seconds
public const ENQUEUE_TIMER = 4; // seconds
+ private ?MessagingPublisher $publisherForMessaging = null;
+
public static function getName(): string
{
return 'schedule-messages';
@@ -27,6 +33,11 @@ class ScheduleMessages extends ScheduleBase
protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void
{
+ $publisherForMessaging = $this->publisherForMessaging ??= new MessagingPublisher(
+ $this->publisherMessaging,
+ new Queue(System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME))
+ );
+
foreach ($this->schedules as $schedule) {
if (!$schedule['active']) {
continue;
@@ -39,16 +50,14 @@ class ScheduleMessages extends ScheduleBase
continue;
}
- \go(function () use ($schedule, $scheduledAt, $dbForPlatform) {
- $queueForMessaging = new Messaging($this->publisherMessaging);
-
+ \go(function () use ($schedule, $scheduledAt, $dbForPlatform, $publisherForMessaging) {
$this->updateProjectAccess($schedule['project'], $dbForPlatform);
- $queueForMessaging
- ->setType(MESSAGE_SEND_TYPE_EXTERNAL)
- ->setMessageId($schedule['resourceId'])
- ->setProject($schedule['project'])
- ->trigger();
+ $publisherForMessaging->enqueue(new MessagingMessage(
+ type: MESSAGE_SEND_TYPE_EXTERNAL,
+ project: $schedule['project'],
+ messageId: $schedule['resourceId'],
+ ));
$dbForPlatform->deleteDocument(
'schedules',
diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php
index 4d04a3c92c..af3d145f85 100644
--- a/src/Appwrite/Platform/Workers/Certificates.php
+++ b/src/Appwrite/Platform/Workers/Certificates.php
@@ -5,8 +5,9 @@ namespace Appwrite\Platform\Workers;
use Appwrite\Certificates\Adapter as CertificatesAdapter;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Publisher\Certificate;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
@@ -50,7 +51,7 @@ class Certificates extends Action
->desc('Certificates worker')
->inject('message')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('queueForEvents')
->inject('queueForWebhooks')
->inject('queueForFunctions')
@@ -66,7 +67,7 @@ class Certificates extends Action
/**
* @param Message $message
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
* @param Func $queueForFunctions
@@ -83,7 +84,7 @@ class Certificates extends Action
public function action(
Message $message,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -116,7 +117,7 @@ class Certificates extends Action
break;
case \Appwrite\Event\Certificate::ACTION_GENERATION:
- $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
+ $this->handleCertificateGenerationAction($domain, $domainType, $dbForPlatform, $publisherForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $authorization, $skipRenewCheck, $plan, $validationDomain);
break;
default:
@@ -209,7 +210,7 @@ class Certificates extends Action
* @param Domain $domain
* @param ?string $domainType
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Event $queueForEvents
* @param Webhook $queueForWebhooks
* @param Func $queueForFunctions
@@ -233,7 +234,7 @@ class Certificates extends Action
Domain $domain,
?string $domainType,
Database $dbForPlatform,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Event $queueForEvents,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -358,7 +359,7 @@ class Certificates extends Action
$rule->setAttribute('status', RULE_STATUS_CERTIFICATE_GENERATION_FAILED);
// Send email to security email
- $this->notifyError($domain->get(), $e->getMessage(), $attempts, $queueForMails, $plan);
+ $this->notifyError($domain->get(), $e->getMessage(), $attempts, $publisherForMails, $plan);
throw $e;
} finally {
@@ -524,12 +525,12 @@ class Certificates extends Action
* @param string $domain Domain that caused the error
* @param string $errorMessage Verbose error message
* @param int $attempt How many times it failed already
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
* @throws Exception
*/
- private function notifyError(string $domain, string $errorMessage, int $attempt, Mail $queueForMails, array $plan): void
+ private function notifyError(string $domain, string $errorMessage, int $attempt, MailPublisher $publisherForMails, array $plan): void
{
// Log error into console
Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage);
@@ -560,14 +561,14 @@ class Certificates extends Action
$subject = $locale->getText("emails.certificate.subject");
$preview = $locale->getText("emails.certificate.preview");
- $queueForMails
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body)
- ->setName('Appwrite Administrator')
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
- ->setVariables($emailVariables)
- ->setRecipient(System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')))
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ recipient: System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')),
+ name: 'Appwrite Administrator',
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl',
+ body: $body,
+ preview: $preview,
+ variables: $emailVariables,
+ ));
}
}
diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php
index 7c591cd268..72dcd6b287 100644
--- a/src/Appwrite/Platform/Workers/Deletes.php
+++ b/src/Appwrite/Platform/Workers/Deletes.php
@@ -222,11 +222,25 @@ class Deletes extends Action
$this->deleteExpiredPresences($project, $getProjectDB, $publisherForUsage);
$this->deleteOldDeployments($queueForDeletes, $project, $getProjectDB);
break;
+ case DELETE_TYPE_REPORT:
+ $this->deleteReport($dbForPlatform, $project, $document);
+ break;
default:
throw new \Exception('No delete operation for type: ' . \strval($type));
}
}
+ private function deleteReport(Database $dbForPlatform, Document $project, Document $report): void
+ {
+ $projectInternalId = $project->getSequence();
+ $reportInternalId = $report->getSequence();
+
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::equal('reportInternalId', [$reportInternalId]),
+ ], $dbForPlatform);
+ }
+
private function cleanDatabase(
Document $databaseDoc,
callable $executionActionPerDatabase,
@@ -722,6 +736,26 @@ class Deletes extends Action
Console::error('Failed to delete schedules: ' . $th->getMessage());
}
+ // Delete Advisor insights
+ try {
+ $this->deleteByGroup('insights', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete insights: ' . $th->getMessage());
+ }
+
+ // Delete Advisor reports
+ try {
+ $this->deleteByGroup('reports', [
+ Query::equal('projectInternalId', [$projectInternalId]),
+ Query::orderAsc()
+ ], $dbForPlatform);
+ } catch (Throwable $th) {
+ Console::error('Failed to delete reports: ' . $th->getMessage());
+ }
+
/**
* @var Database $dbForProject
*/
diff --git a/src/Appwrite/Platform/Workers/Messaging.php b/src/Appwrite/Platform/Workers/Messaging.php
index 03adebc4b5..649fab5233 100644
--- a/src/Appwrite/Platform/Workers/Messaging.php
+++ b/src/Appwrite/Platform/Workers/Messaging.php
@@ -162,21 +162,26 @@ class Messaging extends Action
}
if (\count($userIds) > 0) {
- $users = $dbForProject->find('users', [
- Query::equal('$id', $userIds),
- Query::limit(\count($userIds)),
- ]);
- foreach ($users as $user) {
- $targets = \array_filter($user->getAttribute('targets'), function (Document $target) use ($providerType) {
- return $target->getAttribute('providerType') === $providerType;
- });
+ $limit = 1000;
+ $offset = 0;
+
+ do {
+ $targets = $dbForProject->find('targets', [
+ Query::equal('userId', $userIds),
+ Query::select(['providerId', 'identifier']),
+ Query::equal('providerType', [$providerType]),
+ Query::limit($limit),
+ Query::offset($offset),
+ ]);
\array_push($allTargets, ...$targets);
- }
+ $offset += \count($targets);
+ } while (\count($targets) === $limit);
}
if (\count($targetIds) > 0) {
$targets = $dbForProject->find('targets', [
+ Query::select(['providerId', 'identifier']),
Query::equal('$id', $targetIds),
Query::equal('providerType', [$providerType]),
Query::limit(\count($targetIds)),
diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php
index 3fd86baea9..d1da91a496 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -3,9 +3,10 @@
namespace Appwrite\Platform\Workers;
use Ahc\Jwt\JWT;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Message\Migration;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Event\Realtime;
use Appwrite\Extend\Exception;
@@ -30,6 +31,7 @@ use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Destinations\JSON as DestinationJSON;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Resource;
use Utopia\Migration\Resources\Database\Database as ResourceDatabase;
@@ -101,7 +103,7 @@ class Migrations extends Action
->inject('queueForRealtime')
->inject('deviceForMigrations')
->inject('deviceForFiles')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('usage')
->inject('publisherForUsage')
->inject('plan')
@@ -123,7 +125,7 @@ class Migrations extends Action
Realtime $queueForRealtime,
Device $deviceForMigrations,
Device $deviceForFiles,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Context $usage,
UsagePublisher $publisherForUsage,
array $plan,
@@ -162,7 +164,7 @@ class Migrations extends Action
$this->processMigration(
$migration,
$queueForRealtime,
- $queueForMails,
+ $publisherForMails,
$usage,
$publisherForUsage,
$platform,
@@ -196,13 +198,13 @@ class Migrations extends Action
$projectDB = null;
$useAppwriteApiSource = false;
if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) {
- throw new \Exception('Source projectId is required for Appwrite migrations');
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED);
}
if (! empty($credentials['projectId'])) {
$this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']);
if ($this->sourceProject->isEmpty()) {
- throw new \Exception('Source project not found for provided projectId');
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND);
}
$sourceRegion = $this->sourceProject->getAttribute('region', 'default');
@@ -265,7 +267,7 @@ class Migrations extends Action
$this->deviceForMigrations,
$this->dbForProject,
),
- default => throw new \Exception('Invalid source type'),
+ default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID),
};
$resources = $migration->getAttribute('resources', []);
@@ -291,6 +293,8 @@ class Migrations extends Action
$this->dbForProject,
$this->getDatabasesDB,
Config::getParam('collections', [])['databases']['collections'],
+ OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail,
+ $this->resolveDestinationDatabaseDsn(...),
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
@@ -310,7 +314,20 @@ class Migrations extends Action
$options['filename'],
$options['columns'] ?? [],
),
- default => throw new \Exception('Invalid destination type'),
+ default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID),
+ };
+ }
+
+ /**
+ * Legacy / tablesdb databases route to the destination project's DSN (same as a fresh
+ * Databases create), while documentsdb / vectorsdb keep the source DSN â the dedicated-DB
+ * backfill that would re-point them is not run during migrations.
+ */
+ private function resolveDestinationDatabaseDsn(ResourceDatabase $resource): string
+ {
+ return match ($resource->getType()) {
+ DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB => (string) $resource->getDatabase(),
+ default => (string) $this->project->getAttribute('database', ''),
};
}
@@ -424,7 +441,7 @@ class Migrations extends Action
protected function processMigration(
Document $migration,
Realtime $queueForRealtime,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Context $usage,
UsagePublisher $publisherForUsage,
array $platform,
@@ -436,6 +453,7 @@ class Migrations extends Action
$transfer = $source = $destination = null;
$aggregatedResources = [];
+ $caughtError = null;
$host = System::getEnv('_APP_MIGRATION_HOST');
if (empty($host)) {
@@ -529,7 +547,6 @@ class Migrations extends Action
if (!empty($sourceErrors) || ! empty($destinationErrors)) {
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors));
return;
}
@@ -544,35 +561,69 @@ class Migrations extends Action
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- ]);
+ $caughtError = $th;
+ // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its
+ // registry-driven isPublishable() flag; library-thrown Migration\Exception is
+ // always user-facing; anything else is unknown and surfaced to Sentry.
+ if ($th instanceof Exception) {
+ $publish = $th->isPublishable();
+ } elseif ($th instanceof MigrationException) {
+ $publish = false;
+ } else {
+ $publish = true;
+ }
+
+ if ($publish) {
+ call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
+ 'migrationId' => $migration->getId(),
+ 'source' => $migration->getAttribute('source') ?? '',
+ 'destination' => $migration->getAttribute('destination') ?? '',
+ ]);
+ }
} finally {
try {
+ $sourceErrors = $source?->getErrors() ?? [];
+ $destinationErrors = $destination?->getErrors() ?? [];
+
+ if ($caughtError !== null) {
+ if ($caughtError instanceof MigrationException) {
+ // library-thrown, message constructed by us
+ $bubbled = $caughtError;
+ } elseif ($caughtError instanceof Exception) {
+ // typed AppwriteException â message comes from the curated registry
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: $caughtError->getMessage(),
+ code: $caughtError->getCode(),
+ previous: $caughtError,
+ );
+ } else {
+ // unknown throwable â raw message may embed internal hostnames,
+ // DSNs, tokens, etc. Replace with a generic user-facing string;
+ // the original is preserved on `previous:` for Sentry.
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: 'Migration failed due to an unexpected error.',
+ code: $caughtError->getCode() ?: 500,
+ previous: $caughtError,
+ );
+ }
+ $destinationErrors[] = $bubbled;
+ }
+
+ $migration->setAttribute('errors', $this->sanitizeErrors(
+ $sourceErrors,
+ $destinationErrors,
+ ));
+
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
if ($migration->getAttribute('status', '') === 'failed') {
Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')');
- $sourceErrors = $source?->getErrors() ?? [];
- $destinationErrors = $destination?->getErrors() ?? [];
-
- foreach ([...$sourceErrors, ...$destinationErrors] as $error) {
- /** @var MigrationException $error */
- if ($error->getCode() === 0 || $error->getCode() >= 500) {
- ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- 'resourceName' => $error->getResourceName(),
- 'resourceGroup' => $error->getResourceGroup(),
- ]);
- }
- }
-
$source?->error();
$destination?->error();
}
@@ -594,7 +645,7 @@ class Migrations extends Action
}
$destination_type = $migration->getAttribute('destination');
if ($destination_type === DestinationCSV::getName() || $destination_type === DestinationJSON::getName()) {
- $this->handleDataExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization);
+ $this->handleDataExportComplete($project, $migration, $publisherForMails, $queueForRealtime, $platform, $authorization);
}
} finally {
$source?->cleanup();
@@ -621,7 +672,7 @@ class Migrations extends Action
*
* @param Document $project
* @param Document $migration
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param Realtime $queueForRealtime
* @param array $platform
* @param Authorization $authorization
@@ -630,7 +681,7 @@ class Migrations extends Action
protected function handleDataExportComplete(
Document $project,
Document $migration,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
Realtime $queueForRealtime,
array $platform,
Authorization $authorization,
@@ -682,7 +733,7 @@ class Migrations extends Action
project: $project,
user: $user,
options: $options,
- queueForMails: $queueForMails,
+ publisherForMails: $publisherForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
sizeMB: $sizeMB
@@ -745,7 +796,7 @@ class Migrations extends Action
project: $project,
user: $user,
options: $options,
- queueForMails: $queueForMails,
+ publisherForMails: $publisherForMails,
platform: $platform,
exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV',
downloadUrl: $downloadUrl
@@ -759,7 +810,7 @@ class Migrations extends Action
* @param Document $project
* @param Document $user The user who triggered the operation
* @param array $options Migration options
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $platform
* @param string $downloadUrl Download URL for successful exports
* @param float $sizeMB File size in MB for failed exports
@@ -771,7 +822,7 @@ class Migrations extends Action
Document $project,
Document $user,
array $options,
- Mail $queueForMails,
+ MailPublisher $publisherForMails,
array $platform,
string $exportType = 'CSV',
string $downloadUrl = '',
@@ -841,17 +892,18 @@ class Migrations extends Action
'type' => $exportType,
];
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($emailBody)
- ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl')
- ->setVariables($emailVariables)
- ->setName($user->getAttribute('name', $user->getAttribute('email')))
- ->setRecipient($user->getAttribute('email'))
- ->setSenderName($platform['emailSenderName'])
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name', $user->getAttribute('email')),
+ subject: $subject,
+ bodyTemplate: __DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl',
+ body: $emailBody,
+ preview: $preview,
+ variables: $emailVariables,
+ customMailOptions: ['senderName' => $platform['emailSenderName']],
+ platform: $platform,
+ ));
Console::info("CSV export {$emailType} notification email sent to " . $user->getAttribute('email'));
}
diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php
index a7f4595966..973e487de5 100644
--- a/src/Appwrite/Platform/Workers/Webhooks.php
+++ b/src/Appwrite/Platform/Workers/Webhooks.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Workers;
-use Appwrite\Event\Mail;
+use Appwrite\Event\Message\Mail as MailMessage;
use Appwrite\Event\Message\Usage as UsageMessage;
+use Appwrite\Event\Publisher\Mail as MailPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
use Appwrite\Template\Template;
use Appwrite\Usage\Context as UsageContext;
@@ -36,7 +37,7 @@ class Webhooks extends Action
->inject('message')
->inject('project')
->inject('dbForPlatform')
- ->inject('queueForMails')
+ ->inject('publisherForMails')
->inject('publisherForUsage')
->inject('log')
->inject('plan')
@@ -47,14 +48,14 @@ class Webhooks extends Action
* @param Message $message
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param UsagePublisher $publisherForUsage
* @param Log $log
* @param array $plan
* @return void
* @throws Exception
*/
- public function action(Message $message, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void
+ public function action(Message $message, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, UsagePublisher $publisherForUsage, Log $log, array $plan): void
{
$this->errors = [];
$payload = $message->getPayload();
@@ -73,7 +74,7 @@ class Webhooks extends Action
foreach ($project->getAttribute('webhooks', []) as $webhook) {
if (array_intersect($webhook->getAttribute('events', []), $events)) {
- $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $queueForMails, $publisherForUsage, $plan);
+ $this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForPlatform, $publisherForMails, $publisherForUsage, $plan);
}
}
@@ -89,11 +90,11 @@ class Webhooks extends Action
* @param Document $user
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
*/
- private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, Mail $queueForMails, UsagePublisher $publisherForUsage, array $plan): void
+ private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, UsagePublisher $publisherForUsage, array $plan): void
{
if ($webhook->getAttribute('enabled') !== true) {
return;
@@ -171,7 +172,7 @@ class Webhooks extends Action
if ($attempts >= \intval(System::getEnv('_APP_WEBHOOK_MAX_FAILED_ATTEMPTS', '10'))) {
$webhook->setAttribute('enabled', false);
$updatePayload['enabled'] = false;
- $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForPlatform, $queueForMails, $plan);
+ $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForPlatform, $publisherForMails, $plan);
}
$dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document($updatePayload));
@@ -203,11 +204,11 @@ class Webhooks extends Action
* @param Document $webhook
* @param Document $project
* @param Database $dbForPlatform
- * @param Mail $queueForMails
+ * @param MailPublisher $publisherForMails
* @param array $plan
* @return void
*/
- public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForPlatform, Mail $queueForMails, array $plan): void
+ public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForPlatform, MailPublisher $publisherForMails, array $plan): void
{
$memberships = $dbForPlatform->find('memberships', [
Query::equal('teamInternalId', [$project->getAttribute('teamInternalId')]),
@@ -251,18 +252,16 @@ class Webhooks extends Action
->setParam('{{message}}', $template->render())
->setParam('{{year}}', date("Y"));
- $queueForMails
- ->setProject($project)
- ->setSubject($subject)
- ->setPreview($preview)
- ->setBody($body->render());
-
foreach ($users as $user) {
- $queueForMails
- ->setVariables(['user' => $user->getAttribute('name', '')])
- ->setName($user->getAttribute('name', ''))
- ->setRecipient($user->getAttribute('email'))
- ->trigger();
+ $publisherForMails->enqueue(new MailMessage(
+ project: $project,
+ recipient: $user->getAttribute('email'),
+ name: $user->getAttribute('name', ''),
+ subject: $subject,
+ body: $body->render(),
+ preview: $preview,
+ variables: ['user' => $user->getAttribute('name', '')],
+ ));
}
}
}
diff --git a/src/Appwrite/Promises/Swoole.php b/src/Appwrite/Promises/Swoole.php
index 9c06fbda2f..03c901ead6 100644
--- a/src/Appwrite/Promises/Swoole.php
+++ b/src/Appwrite/Promises/Swoole.php
@@ -8,7 +8,7 @@ use Utopia\DI\Container;
class Swoole extends Promise
{
- private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia_http_request_container';
+ private const REQUEST_CONTAINER_CONTEXT_KEY = '__utopia__';
public function __construct(?callable $executor = null)
{
diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php
index 30df5acf52..fc67dedb13 100644
--- a/src/Appwrite/SDK/Specification/Format.php
+++ b/src/Appwrite/SDK/Specification/Format.php
@@ -68,6 +68,17 @@ abstract class Format
'mock-unverified'
],
],
+ [
+ 'namespace' => 'project',
+ 'methods' => [
+ 'getOAuth2Provider'
+ ],
+ 'parameter' => 'providerId',
+ 'excludeKeys' => [
+ 'mock',
+ 'mock-unverified'
+ ],
+ ],
];
/**
@@ -743,13 +754,31 @@ abstract class Format
break;
case 'project':
switch ($method) {
+ case 'updateAuthMethod':
+ switch ($param) {
+ case 'methodId':
+ return 'ProjectAuthMethodId';
+ }
+ break;
+ case 'getPolicy':
+ switch ($param) {
+ case 'policyId':
+ return 'ProjectPolicyId';
+ }
+ break;
+ case 'getOAuth2Provider':
+ switch ($param) {
+ case 'providerId':
+ return 'ProjectOAuthProviderId';
+ }
+ break;
case 'getEmailTemplate':
case 'updateEmailTemplate':
switch ($param) {
case 'templateId':
- return 'EmailTemplateType';
+ return 'ProjectEmailTemplateId';
case 'locale':
- return 'EmailTemplateLocale';
+ return 'ProjectEmailTemplateLocale';
}
break;
case 'getUsage':
@@ -758,6 +787,39 @@ abstract class Format
return 'ProjectUsageRange';
}
break;
+ case 'updateProtocol':
+ switch ($param) {
+ case 'protocolId':
+ return 'ProjectProtocolId';
+ }
+ break;
+ case 'updateService':
+ switch ($param) {
+ case 'serviceId':
+ return 'ProjectServiceId';
+ }
+ break;
+ case 'updateSMTP':
+ case 'createSMTPTest':
+ switch ($param) {
+ case 'secure':
+ return 'ProjectSMTPSecure';
+ }
+ break;
+ case 'updateOAuth2Google':
+ switch ($param) {
+ case 'prompt':
+ return 'ProjectOAuth2GooglePrompt';
+ }
+ break;
+ case 'createKey':
+ case 'createEphemeralKey':
+ case 'updateKey':
+ switch ($param) {
+ case 'scopes':
+ return 'ProjectKeyScopes';
+ }
+ break;
}
break;
case 'projects':
@@ -990,8 +1052,12 @@ abstract class Format
return self::REQUEST_PARAMETER_OVERRIDES;
}
- public function getResponseEnumName(string $model, string $param): ?string
+ public function getResponseEnumName(string $model, string $param, ?string $enumSDKName = null): ?string
{
+ if ($enumSDKName) {
+ return $enumSDKName;
+ }
+
if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') {
return 'PlatformType';
}
diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
index e2fb383c8c..833a066e0c 100644
--- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
+++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
@@ -524,6 +524,7 @@ class OpenAPI3 extends Format
case \Appwrite\Utopia\Database\Validator\Queries\Identities::class:
case \Appwrite\Utopia\Database\Validator\Queries\Indexes::class:
case \Appwrite\Utopia\Database\Validator\Queries\Installations::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Memberships::class:
case \Appwrite\Utopia\Database\Validator\Queries\Messages::class:
case \Appwrite\Utopia\Database\Validator\Queries\Migrations::class:
@@ -755,7 +756,18 @@ class OpenAPI3 extends Format
$node['schema']['default'] = $param['default'];
}
- if (false !== \strpos($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -796,7 +808,14 @@ class OpenAPI3 extends Format
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
@@ -976,13 +995,13 @@ class OpenAPI3 extends Format
if ($rule['type'] === 'enum' && !empty($rule['enum'])) {
if ($rule['array']) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['items']['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['items']['x-enum-name'] = $enumName;
}
} else {
$output['components']['schemas'][$model->getType()]['properties'][$name]['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['components']['schemas'][$model->getType()]['properties'][$name]['x-enum-name'] = $enumName;
}
diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php
index 30833378a9..b4923c5432 100644
--- a/src/Appwrite/SDK/Specification/Format/Swagger2.php
+++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php
@@ -511,6 +511,7 @@ class Swagger2 extends Format
case \Utopia\Database\Validator\Queries::class:
case \Utopia\Database\Validator\Queries\Document::class:
case \Utopia\Database\Validator\Queries\Documents::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Columns::class:
case \Appwrite\Utopia\Database\Validator\Queries\Tables::class:
$node['type'] = 'array';
@@ -722,7 +723,18 @@ class Swagger2 extends Format
$node['default'] = $param['default'];
}
- if (\str_contains($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -767,7 +779,14 @@ class Swagger2 extends Format
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
@@ -963,13 +982,13 @@ class Swagger2 extends Format
if ($rule['type'] === 'enum' && !empty($rule['enum'])) {
if ($rule['array']) {
$output['definitions'][$model->getType()]['properties'][$name]['items']['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['definitions'][$model->getType()]['properties'][$name]['items']['x-enum-name'] = $enumName;
}
} else {
$output['definitions'][$model->getType()]['properties'][$name]['enum'] = \array_values($rule['enum']);
- $enumName = $this->getResponseEnumName($model->getType(), $name);
+ $enumName = $this->getResponseEnumName($model->getType(), $name, $rule['enumSDKName'] ?? null);
if ($enumName) {
$output['definitions'][$model->getType()]['properties'][$name]['x-enum-name'] = $enumName;
}
diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
new file mode 100644
index 0000000000..82ca911747
--- /dev/null
+++ b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
@@ -0,0 +1,20 @@
+getMethod();
+
+ if (!\in_array($method, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) {
+ $this->message = 'Invalid query method: ' . $method;
+ return false;
+ }
+
+ $cursor = $value->getValue();
+
+ $validator = new Text(256);
+ if (!$validator->isValid($cursor)) {
+ $this->message = 'Invalid cursor: ' . $validator->getDescription();
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getMethodType(): string
+ {
+ return self::METHOD_TYPE_CURSOR;
+ }
+}
diff --git a/src/Appwrite/Utopia/Request/Filters/V26.php b/src/Appwrite/Utopia/Request/Filters/V26.php
new file mode 100644
index 0000000000..00310ddff9
--- /dev/null
+++ b/src/Appwrite/Utopia/Request/Filters/V26.php
@@ -0,0 +1,37 @@
+stripProjectMetadata($content);
+ break;
+ }
+
+ return $content;
+ }
+
+ protected function stripProjectMetadata(array $content): array
+ {
+ unset(
+ $content['description'],
+ $content['logo'],
+ $content['url'],
+ $content['legalName'],
+ $content['legalCountry'],
+ $content['legalState'],
+ $content['legalCity'],
+ $content['legalAddress'],
+ $content['legalTaxId'],
+ );
+
+ return $content;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php
index 9f03c34632..66e4a5f4ae 100644
--- a/src/Appwrite/Utopia/Response.php
+++ b/src/Appwrite/Utopia/Response.php
@@ -252,6 +252,9 @@ class Response extends SwooleResponse
// Project
public const MODEL_PROJECT = 'project';
public const MODEL_PROJECT_LIST = 'projectList';
+ public const MODEL_PROJECT_AUTH_METHOD = 'projectAuthMethod';
+ public const MODEL_PROJECT_SERVICE = 'projectService';
+ public const MODEL_PROJECT_PROTOCOL = 'projectProtocol';
public const MODEL_WEBHOOK = 'webhook';
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
@@ -335,6 +338,13 @@ class Response extends SwooleResponse
public const MODEL_HEALTH_CERTIFICATE = 'healthCertificate';
public const MODEL_HEALTH_STATUS_LIST = 'healthStatusList';
+ // Advisor
+ public const MODEL_INSIGHT = 'insight';
+ public const MODEL_INSIGHT_LIST = 'insightList';
+ public const MODEL_INSIGHT_CTA = 'insightCTA';
+ public const MODEL_REPORT = 'report';
+ public const MODEL_REPORT_LIST = 'reportList';
+
// Console
public const MODEL_CONSOLE_VARIABLES = 'consoleVariables';
public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter';
@@ -438,9 +448,10 @@ class Response extends SwooleResponse
return isset(self::$models[$key]);
}
- public function applyFilters(array $data, string $model): array
+ public function applyFilters(array $data, string $model, Document $raw): array
{
foreach ($this->filters as $filter) {
+ $filter->setRawContent($raw);
$data = $filter->parse($data, $model);
}
@@ -460,7 +471,7 @@ class Response extends SwooleResponse
public function dynamic(Document $document, string $model): void
{
$output = $this->output(clone $document, $model);
- $output = $this->applyFilters($output, $model);
+ $output = $this->applyFilters($output, $model, raw: clone $document);
switch ($this->getContentType()) {
case self::CONTENT_TYPE_JSON:
diff --git a/src/Appwrite/Utopia/Response/Filter.php b/src/Appwrite/Utopia/Response/Filter.php
index bd82467f81..13833be328 100644
--- a/src/Appwrite/Utopia/Response/Filter.php
+++ b/src/Appwrite/Utopia/Response/Filter.php
@@ -2,8 +2,15 @@
namespace Appwrite\Utopia\Response;
+use Utopia\Database\Document;
+
abstract class Filter
{
+ /**
+ * @var ?Document $rawContent
+ */
+ protected ?Document $rawContent = null;
+
/**
* Parse the content to another format.
*
@@ -14,6 +21,10 @@ abstract class Filter
*/
abstract public function parse(array $content, string $model): array;
+ public function setRawContent(Document $rawContent): void
+ {
+ $this->rawContent = $rawContent;
+ }
/**
* Handle list
diff --git a/src/Appwrite/Utopia/Response/Filters/V26.php b/src/Appwrite/Utopia/Response/Filters/V26.php
new file mode 100644
index 0000000000..3867ba907f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Filters/V26.php
@@ -0,0 +1,227 @@
+ $this->parseProject($content, $this->rawContent),
+ Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', function ($item) {
+ $projectId = $item['$id'] ?? '';
+
+ $rawProjects = $this->rawContent->getAttribute('projects', []);
+ $rawProject = new Document();
+ foreach ($rawProjects as $rawItem) {
+ if ($rawItem->getId() === $projectId) {
+ $rawProject = $rawItem;
+ break;
+ }
+ }
+
+ return $this->parseProject($item, $rawProject);
+ }),
+ default => $content,
+ };
+ }
+
+ private function parseProject(array $content, Document $raw): array
+ {
+ $this->expandAuthMethods($content);
+ $this->expandServices($content);
+ $this->expandProtocols($content);
+
+ unset($content['authMethods'], $content['services'], $content['protocols']);
+
+ $auths = new Document($raw->getAttribute('auths', []));
+ $content['authLimit'] = $auths->getAttribute('limit', 0);
+ $content['authDuration'] = $auths->getAttribute('duration', TOKEN_EXPIRATION_LOGIN_LONG);
+ $content['authSessionsLimit'] = $auths->getAttribute('maxSessions', 0);
+ $content['authPasswordHistory'] = $auths->getAttribute('passwordHistory', 0);
+ $content['authPasswordDictionary'] = $auths->getAttribute('passwordDictionary', false);
+ $content['authPersonalDataCheck'] = $auths->getAttribute('personalDataCheck', false);
+ $content['authDisposableEmails'] = $auths->getAttribute('disposableEmails', false);
+ $content['authCanonicalEmails'] = $auths->getAttribute('canonicalEmails', false);
+ $content['authFreeEmails'] = $auths->getAttribute('freeEmails', false);
+ $content['authMockNumbers'] = $auths->getAttribute('mockNumbers', []);
+ $content['authSessionAlerts'] = $auths->getAttribute('sessionAlerts', false);
+ $content['authMembershipsUserName'] = $auths->getAttribute('membershipsUserName', false);
+ $content['authMembershipsUserEmail'] = $auths->getAttribute('membershipsUserEmail', false);
+ $content['authMembershipsMfa'] = $auths->getAttribute('membershipsMfa', false);
+ $content['authMembershipsUserId'] = $auths->getAttribute('membershipsUserId', false);
+ $content['authMembershipsUserPhone'] = $auths->getAttribute('membershipsUserPhone', false);
+ $content['authInvalidateSessions'] = $auths->getAttribute('invalidateSessions', false);
+
+ $content['description'] = $raw->getAttribute('description', '');
+ $content['logo'] = $raw->getAttribute('logo', '');
+ $content['url'] = $raw->getAttribute('url', '');
+ $content['legalName'] = $raw->getAttribute('legalName', '');
+ $content['legalCountry'] = $raw->getAttribute('legalCountry', '');
+ $content['legalState'] = $raw->getAttribute('legalState', '');
+ $content['legalCity'] = $raw->getAttribute('legalCity', '');
+ $content['legalAddress'] = $raw->getAttribute('legalAddress', '');
+ $content['legalTaxId'] = $raw->getAttribute('legalTaxId', '');
+
+ $content['oAuthProviders'] = $this->expandOAuthProviders($raw);
+
+ $content['platforms'] = [];
+ foreach ($raw->getAttribute('platforms', []) as $platform) {
+ $content['platforms'][] = $this->parsePlatform($platform);
+ }
+
+ $content['webhooks'] = [];
+ foreach ($raw->getAttribute('webhooks', []) as $webhook) {
+ $content['webhooks'][] = $this->parseWebhook($webhook);
+ }
+
+ $content['keys'] = [];
+ foreach ($raw->getAttribute('keys', []) as $key) {
+ $content['keys'][] = $this->parseKey($key);
+ }
+
+ return $content;
+ }
+
+ private function parsePlatform(Document $platform): array
+ {
+ $type = $platform->getAttribute('type', '');
+ $key = $platform->getAttribute('key', '');
+
+ $result = [
+ '$id' => $platform->getAttribute('$id', ''),
+ '$createdAt' => $platform->getAttribute('$createdAt', ''),
+ '$updatedAt' => $platform->getAttribute('$updatedAt', ''),
+ 'name' => $platform->getAttribute('name', ''),
+ 'type' => $type,
+ ];
+
+ switch ($type) {
+ case Platform::TYPE_ANDROID:
+ $result['applicationId'] = $key;
+ break;
+ case Platform::TYPE_APPLE:
+ $result['bundleIdentifier'] = $key;
+ break;
+ case Platform::TYPE_LINUX:
+ $result['packageName'] = $key;
+ break;
+ case Platform::TYPE_WINDOWS:
+ $result['packageIdentifierName'] = $key;
+ break;
+ default:
+ // Web and backwards-compatibility types are mapped to web
+ $result['hostname'] = $platform->getAttribute('hostname', '');
+ $result['key'] = $key;
+ break;
+ }
+
+ return $result;
+ }
+
+ private function parseWebhook(Document $webhook): array
+ {
+ return [
+ '$id' => $webhook->getAttribute('$id', ''),
+ '$createdAt' => $webhook->getAttribute('$createdAt', ''),
+ '$updatedAt' => $webhook->getAttribute('$updatedAt', ''),
+ 'name' => $webhook->getAttribute('name', ''),
+ 'url' => $webhook->getAttribute('url', ''),
+ 'events' => $webhook->getAttribute('events', []),
+ 'tls' => $webhook->getAttribute('security', true),
+ 'authUsername' => $webhook->getAttribute('httpUser', ''),
+ 'authPassword' => $webhook->getAttribute('httpPass', ''),
+ 'secret' => $webhook->getAttribute('signatureKey', ''),
+ 'enabled' => $webhook->getAttribute('enabled', true),
+ 'logs' => $webhook->getAttribute('logs', ''),
+ 'attempts' => $webhook->getAttribute('attempts', 0),
+ ];
+ }
+
+ private function parseKey(Document $key): array
+ {
+ return [
+ '$id' => $key->getAttribute('$id', ''),
+ '$createdAt' => $key->getAttribute('$createdAt', ''),
+ '$updatedAt' => $key->getAttribute('$updatedAt', ''),
+ 'name' => $key->getAttribute('name', ''),
+ 'expire' => $key->getAttribute('expire', ''),
+ 'scopes' => $key->getAttribute('scopes', []),
+ 'secret' => $key->getAttribute('secret', ''),
+ 'accessedAt' => $key->getAttribute('accessedAt', ''),
+ 'sdks' => $key->getAttribute('sdks', []),
+ ];
+ }
+
+ private function expandAuthMethods(array &$content): void
+ {
+ $authMethods = [];
+ foreach ($content['authMethods'] ?? [] as $method) {
+ $authMethods[$method['$id'] ?? ''] = $method['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('auth', []) as $id => $method) {
+ $key = $method['key'] ?? '';
+ $content['auth' . ucfirst($key)] = $authMethods[$id] ?? true;
+ }
+ }
+
+ private function expandServices(array &$content): void
+ {
+ $services = [];
+ foreach ($content['services'] ?? [] as $service) {
+ $services[$service['$id'] ?? ''] = $service['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('services', []) as $id => $service) {
+ if (!($service['optional'] ?? false)) {
+ continue;
+ }
+ $key = $service['key'] ?? '';
+ $content['serviceStatusFor' . ucfirst($key)] = $services[$id] ?? true;
+ }
+ }
+
+ private function expandProtocols(array &$content): void
+ {
+ $protocols = [];
+ foreach ($content['protocols'] ?? [] as $protocol) {
+ $protocols[$protocol['$id'] ?? ''] = $protocol['enabled'] ?? true;
+ }
+
+ foreach (Config::getParam('protocols', []) as $id => $api) {
+ $key = $api['key'] ?? '';
+ $content['protocolStatusFor' . ucfirst($key)] = $protocols[$id] ?? true;
+ }
+ }
+
+ private function expandOAuthProviders(Document $raw): array
+ {
+ $providers = Config::getParam('oAuthProviders', []);
+ $providerValues = $raw->getAttribute('oAuthProviders', []);
+ $projectProviders = [];
+
+ foreach ($providers as $key => $provider) {
+ if (!($provider['enabled'] ?? false)) {
+ continue;
+ }
+
+ $projectProviders[] = [
+ 'key' => $key,
+ 'name' => $provider['name'] ?? '',
+ 'appId' => $providerValues[$key . 'Appid'] ?? '',
+ 'secret' => '',
+ 'enabled' => $providerValues[$key . 'Enabled'] ?? false,
+ ];
+ }
+
+ return $projectProviders;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php
new file mode 100644
index 0000000000..39a12a9832
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/Insight.php
@@ -0,0 +1,130 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight ID.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Insight creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Insight update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('reportId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Parent report ID. Insights always belong to a report.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so each CTA can pair the right service+method (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex).',
+ 'default' => '',
+ 'example' => 'tablesDBIndex',
+ ])
+ ->addRule('severity', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight severity. One of info, warning, critical.',
+ 'default' => 'info',
+ 'example' => 'warning',
+ ])
+ ->addRule('status', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight status. One of active, dismissed.',
+ 'default' => 'active',
+ 'example' => 'active',
+ ])
+ ->addRule('resourceType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions.',
+ 'default' => '',
+ 'example' => 'databases',
+ ])
+ ->addRule('resourceId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the resource the insight is about.',
+ 'default' => '',
+ 'example' => 'main',
+ ])
+ ->addRule('parentResourceType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Plural noun for the parent resource that contains the insight\'s resource, e.g. an insight about a column index on a table â resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent.',
+ 'default' => '',
+ 'example' => 'tables',
+ ])
+ ->addRule('parentResourceId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the parent resource. Empty when the resource has no parent.',
+ 'default' => '',
+ 'example' => 'orders',
+ ])
+ ->addRule('title', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Insight title.',
+ 'default' => '',
+ 'example' => 'Missing index on collection orders',
+ ])
+ ->addRule('summary', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Short markdown summary describing the insight.',
+ 'default' => '',
+ 'example' => 'Queries against `orders.status` are scanning the full collection.',
+ ])
+ ->addRule('ctas', [
+ 'type' => Response::MODEL_INSIGHT_CTA,
+ 'description' => 'List of call-to-action buttons attached to this insight.',
+ 'default' => [],
+ 'example' => [],
+ 'array' => true,
+ ])
+ ->addRule('analyzedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the insight was analyzed in ISO 8601 format.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ])
+ ->addRule('dismissedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the insight was dismissed in ISO 8601 format. Empty when not dismissed.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ])
+ ->addRule('dismissedBy', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'User ID that dismissed the insight. Empty when not dismissed.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ 'required' => false,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Insight';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_INSIGHT;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/InsightCTA.php b/src/Appwrite/Utopia/Response/Model/InsightCTA.php
new file mode 100644
index 0000000000..3ebd8b5796
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/InsightCTA.php
@@ -0,0 +1,48 @@
+addRule('label', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Human-readable label for the CTA, used in UI.',
+ 'default' => '',
+ 'example' => 'Create missing index',
+ ])
+ ->addRule('service', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Public API service (SDK namespace) the client should invoke. Must match the engine that owns the resource â for index suggestions: databases (legacy), tablesDB, documentsDB, or vectorsDB.',
+ 'default' => '',
+ 'example' => 'tablesDB',
+ ])
+ ->addRule('method', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Public API method on the chosen service the client should invoke when this CTA is triggered.',
+ 'default' => '',
+ 'example' => 'createIndex',
+ ])
+ ->addRule('params', [
+ 'type' => self::TYPE_JSON,
+ 'description' => 'Parameter map the client should pass to the service method when this CTA is triggered. Keys match the target API\'s parameter names (e.g. databaseId/tableId/columns for tablesDB, databaseId/collectionId/attributes for the legacy Databases API).',
+ 'default' => new \stdClass(),
+ 'example' => ['databaseId' => 'main', 'tableId' => 'orders', 'key' => '_idx_status', 'type' => 'key', 'columns' => ['status']],
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'InsightCTA';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_INSIGHT_CTA;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
index 3dbc892631..ebef9aecf7 100644
--- a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
+++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php
@@ -25,6 +25,20 @@ class OAuth2Google extends OAuth2Base
return 'GOCSPX-2k8gsR0000000000000000VNahJj';
}
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->addRule('prompt', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Google OAuth2 prompt values.',
+ 'default' => ['consent'],
+ 'example' => ['consent'],
+ 'array' => true,
+ 'enum' => ['none', 'consent', 'select_account'],
+ ]);
+ }
+
/**
* Get Name
*
diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php
index 36be3b751f..af2a21d551 100644
--- a/src/Appwrite/Utopia/Response/Model/Project.php
+++ b/src/Appwrite/Utopia/Response/Model/Project.php
@@ -12,6 +12,7 @@ class Project extends Model
public function __construct()
{
$this
+ // Basic project information
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Project ID.',
@@ -36,210 +37,23 @@ class Project extends Model
'default' => '',
'example' => 'New Project',
])
- ->addRule('description', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Project description.',
- 'default' => '',
- 'example' => 'This is a new project.',
- ])
->addRule('teamId', [
'type' => self::TYPE_STRING,
'description' => 'Project team ID.',
'default' => '',
'example' => '1592981250',
])
- ->addRule('logo', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Project logo file ID.',
- 'default' => '',
- 'example' => '5f5c451b403cb',
- ])
- ->addRule('url', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Project website URL.',
- 'default' => '',
- 'example' => '5f5c451b403cb',
- ])
- ->addRule('legalName', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Company legal name.',
- 'default' => '',
- 'example' => 'Company LTD.',
- ])
- ->addRule('legalCountry', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Country code in [ISO 3166-1](http://en.wikipedia.org/wiki/ISO_3166-1) two-character format.',
- 'default' => '',
- 'example' => 'US',
- ])
- ->addRule('legalState', [
- 'type' => self::TYPE_STRING,
- 'description' => 'State name.',
- 'default' => '',
- 'example' => 'New York',
- ])
- ->addRule('legalCity', [
- 'type' => self::TYPE_STRING,
- 'description' => 'City name.',
- 'default' => '',
- 'example' => 'New York City.',
- ])
- ->addRule('legalAddress', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Company Address.',
- 'default' => '',
- 'example' => '620 Eighth Avenue, New York, NY 10018',
- ])
- ->addRule('legalTaxId', [
- 'type' => self::TYPE_STRING,
- 'description' => 'Company Tax ID.',
- 'default' => '',
- 'example' => '131102020',
- ])
- ->addRule('authDuration', [
- 'type' => self::TYPE_INTEGER,
- 'description' => 'Session duration in seconds.',
- 'default' => TOKEN_EXPIRATION_LOGIN_LONG,
- 'example' => 60,
- ])
- ->addRule('authLimit', [
- 'type' => self::TYPE_INTEGER,
- 'description' => 'Max users allowed. 0 is unlimited.',
- 'default' => 0,
- 'example' => 100,
- ])
- ->addRule('authSessionsLimit', [
- 'type' => self::TYPE_INTEGER,
- 'description' => 'Max sessions allowed per user. 100 maximum.',
- 'default' => 10,
- 'example' => 10,
- ])
- ->addRule('authPasswordHistory', [
- 'type' => self::TYPE_INTEGER,
- 'description' => 'Max allowed passwords in the history list per user. Max passwords limit allowed in history is 20. Use 0 for disabling password history.',
- 'default' => 0,
- 'example' => 5,
- ])
- ->addRule('authPasswordDictionary', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to check user\'s password against most commonly used passwords.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authPersonalDataCheck', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to check the user password for similarity with their personal data.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authDisposableEmails', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to disallow disposable email addresses during signup and email updates.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authCanonicalEmails', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to require canonical email addresses during signup and email updates.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authFreeEmails', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to disallow free email addresses during signup and email updates.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMockNumbers', [
- 'type' => Response::MODEL_MOCK_NUMBER,
- 'description' => 'An array of mock numbers and their corresponding verification codes (OTPs).',
- 'default' => [],
- 'array' => true,
- 'example' => [new \stdClass()],
- ])
- ->addRule('authSessionAlerts', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to send session alert emails to users.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMembershipsUserName', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to show user names in the teams membership response.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMembershipsUserEmail', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to show user emails in the teams membership response.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMembershipsMfa', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to show user MFA status in the teams membership response.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMembershipsUserId', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to show user IDs in the teams membership response.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authMembershipsUserPhone', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not to show user phone numbers in the teams membership response.',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('authInvalidateSessions', [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => 'Whether or not all existing sessions should be invalidated on password change',
- 'default' => false,
- 'example' => true,
- ])
- ->addRule('oAuthProviders', [
- 'type' => Response::MODEL_AUTH_PROVIDER,
- 'description' => 'List of Auth Providers.',
- 'default' => [],
- 'example' => [new \stdClass()],
- 'array' => true,
- ])
- ->addRule('platforms', [
- 'type' => [
- Response::MODEL_PLATFORM_WEB,
- Response::MODEL_PLATFORM_APPLE,
- Response::MODEL_PLATFORM_ANDROID,
- Response::MODEL_PLATFORM_WINDOWS,
- Response::MODEL_PLATFORM_LINUX,
- ],
- 'description' => 'List of Platforms.',
- 'default' => [],
- 'example' => new \stdClass(),
- 'array' => true,
- ])
- ->addRule('webhooks', [
- 'type' => Response::MODEL_WEBHOOK,
- 'description' => 'List of Webhooks.',
- 'default' => [],
- 'example' => new \stdClass(),
- 'array' => true,
- ])
- ->addRule('keys', [
- 'type' => Response::MODEL_KEY,
- 'description' => 'List of API Keys.',
- 'default' => [],
- 'example' => new \stdClass(),
- 'array' => true,
- ])
+
+ // Resource: Dev Keys
->addRule('devKeys', [
'type' => Response::MODEL_DEV_KEY,
- 'description' => 'List of dev keys.',
+ 'description' => 'Deprecated since 1.9.5: List of dev keys.',
'default' => [],
'example' => new \stdClass(),
'array' => true,
])
+
+ // Resource: SMTP
->addRule('smtpEnabled', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Status for custom SMTP',
@@ -301,6 +115,8 @@ class Project extends Model
'default' => '',
'example' => 'tls',
])
+
+ // Resource: Ping
->addRule('pingCount', [
'type' => self::TYPE_INTEGER,
'description' => 'Number of times the ping was received for this project.',
@@ -313,6 +129,8 @@ class Project extends Model
'default' => '',
'example' => self::TYPE_DATETIME_EXAMPLE,
])
+
+ // Resource: Labels
->addRule('labels', [
'type' => self::TYPE_STRING,
'description' => 'Labels for the project.',
@@ -320,64 +138,42 @@ class Project extends Model
'example' => ['vip'],
'array' => true,
])
+
+ // Resource: Billing
->addRule('status', [
'type' => self::TYPE_STRING,
'description' => 'Project status.',
'default' => 'active',
'example' => 'active',
])
+
+ // Resource: Auth methods
+ ->addRule('authMethods', [
+ 'type' => Response::MODEL_PROJECT_AUTH_METHOD,
+ 'description' => 'List of auth methods.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
+
+ // Resource: Services
+ ->addRule('services', [
+ 'type' => Response::MODEL_PROJECT_SERVICE,
+ 'description' => 'List of services.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
+
+ // Resource: Protocols
+ ->addRule('protocols', [
+ 'type' => Response::MODEL_PROJECT_PROTOCOL,
+ 'description' => 'List of protocols.',
+ 'default' => [],
+ 'example' => new \stdClass(),
+ 'array' => true,
+ ])
;
-
- $services = Config::getParam('services', []);
- $auth = Config::getParam('auth', []);
-
- foreach ($auth as $index => $method) {
- $name = $method['name'] ?? '';
- $key = $method['key'] ?? '';
-
- $this
- ->addRule('auth' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' auth method status',
- 'example' => true,
- 'default' => true,
- ])
- ;
- }
-
- foreach ($services as $service) {
- if (!$service['optional']) {
- continue;
- }
-
- $name = $service['name'] ?? '';
- $key = $service['key'] ?? '';
-
- $this
- ->addRule('serviceStatusFor' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' service status',
- 'example' => true,
- 'default' => true,
- ])
- ;
- }
-
- $apis = Config::getParam('protocols', []);
-
- foreach ($apis as $api) {
- $name = $api['name'] ?? '';
- $key = $api['key'] ?? '';
-
- $this
- ->addRule('protocolStatusFor' . ucfirst($key), [
- 'type' => self::TYPE_BOOLEAN,
- 'description' => $name . ' protocol status',
- 'example' => true,
- 'default' => true,
- ])
- ;
- }
}
/**
@@ -408,10 +204,9 @@ class Project extends Model
public function filter(Document $document): Document
{
$this->expandSmtpFields($document);
- $this->expandServiceFields($document);
- $this->expandApiFields($document);
- $this->expandAuthFields($document);
- $this->expandOAuthProviders($document);
+ $this->expandServices($document);
+ $this->expandProtocols($document);
+ $this->expandAuthMethods($document);
return $document;
}
@@ -422,8 +217,8 @@ class Project extends Model
return;
}
- // SMTP
$smtp = $document->getAttribute('smtp', []);
+
$document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false);
$document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? '');
$document->setAttribute('smtpSenderName', $smtp['senderName'] ?? '');
@@ -436,100 +231,52 @@ class Project extends Model
$document->setAttribute('smtpSecure', $smtp['secure'] ?? '');
}
- private function expandServiceFields(Document $document): void
+ private function expandServices(Document $document): void
{
- if (!$document->isSet('services')) {
- return;
- }
-
$values = $document->getAttribute('services', []);
- $services = Config::getParam('services', []);
+ $services = [];
- foreach ($services as $service) {
+ foreach (Config::getParam('services', []) as $id => $service) {
if (!$service['optional']) {
continue;
}
- $key = $service['key'] ?? '';
- $value = $values[$key] ?? true;
- $document->setAttribute('serviceStatusFor' . ucfirst($key), $value);
- }
- }
- private function expandApiFields(Document $document): void
- {
- if (!$document->isSet('apis')) {
- return;
- }
-
- $values = $document->getAttribute('apis', []);
- $apis = Config::getParam('protocols', []);
-
- foreach ($apis as $api) {
- $key = $api['key'] ?? '';
- $value = $values[$key] ?? true;
- $document->setAttribute('protocolStatusFor' . ucfirst($key), $value);
- }
- }
-
- private function expandAuthFields(Document $document): void
- {
- if (!$document->isSet('auths')) {
- return;
- }
-
- $authValues = $document->getAttribute('auths', []);
- $auth = Config::getParam('auth', []);
-
- $document->setAttribute('authLimit', $authValues['limit'] ?? 0);
- $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG);
- $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0);
- $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
- $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
- $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
- $document->setAttribute('authDisposableEmails', $authValues['disposableEmails'] ?? false);
- $document->setAttribute('authCanonicalEmails', $authValues['canonicalEmails'] ?? false);
- $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false);
- $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []);
- $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false);
- $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false);
- $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false);
- $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false);
- $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false);
- $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false);
- $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false);
-
- foreach ($auth as $method) {
- $key = $method['key'];
- $value = $authValues[$key] ?? true;
- $document->setAttribute('auth' . ucfirst($key), $value);
- }
- }
-
- private function expandOAuthProviders(Document $document): void
- {
- if (!$document->isSet('oAuthProviders')) {
- return;
- }
-
- $providers = Config::getParam('oAuthProviders', []);
- $providerValues = $document->getAttribute('oAuthProviders', []);
- $projectProviders = [];
-
- foreach ($providers as $key => $provider) {
- if (!$provider['enabled']) {
- // Disabled by Appwrite configuration, exclude from response
- continue;
- }
-
- $projectProviders[] = new Document([
- 'key' => $key,
- 'name' => $provider['name'] ?? '',
- 'appId' => $providerValues[$key . 'Appid'] ?? '',
- 'secret' => '', // Write-only: never expose the stored value
- 'enabled' => $providerValues[$key . 'Enabled'] ?? false,
+ $services[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$service['key']] ?? true,
]);
}
- $document->setAttribute('oAuthProviders', $projectProviders);
+ $document->setAttribute('services', $services);
+ }
+
+ private function expandProtocols(Document $document): void
+ {
+ $values = $document->getAttribute('apis', []);
+ $protocols = [];
+
+ foreach (Config::getParam('protocols', []) as $id => $api) {
+ $protocols[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$api['key']] ?? true,
+ ]);
+ }
+
+ $document->setAttribute('protocols', $protocols);
+ }
+
+ private function expandAuthMethods(Document $document): void
+ {
+ $values = $document->getAttribute('auths', []);
+ $authMethods = [];
+
+ foreach (Config::getParam('auth', []) as $id => $method) {
+ $authMethods[] = new Document([
+ '$id' => $id,
+ 'enabled' => $values[$method['key']] ?? true
+ ]);
+ }
+
+ $document->setAttribute('authMethods', $authMethods);
}
}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php b/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php
new file mode 100644
index 0000000000..cb4a7dc93f
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectAuthMethod.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Auth method ID.',
+ 'default' => '',
+ 'example' => 'email-password',
+ 'enum' => \array_keys(Config::getParam('auth', [])),
+ 'enumSDKName' => 'ProjectAuthMethodId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Auth method status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectAuthMethod';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_AUTH_METHOD;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php b/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php
new file mode 100644
index 0000000000..918b1bc630
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectProtocol.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Protocol ID.',
+ 'default' => '',
+ 'example' => 'graphql',
+ 'enum' => \array_keys(Config::getParam('protocols', [])),
+ 'enumSDKName' => 'ProjectProtocolId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Protocol status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectProtocol';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_PROTOCOL;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/ProjectService.php b/src/Appwrite/Utopia/Response/Model/ProjectService.php
new file mode 100644
index 0000000000..2e76dcfbe7
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/ProjectService.php
@@ -0,0 +1,50 @@
+addRule('$id', [
+ 'type' => self::TYPE_ENUM,
+ 'description' => 'Service ID.',
+ 'default' => '',
+ 'example' => 'sites',
+ 'enum' => \array_keys(\array_filter(Config::getParam('services', []), fn ($element) => $element['optional'])),
+ 'enumSDKName' => 'ProjectServiceId',
+ ])
+ ->addRule('enabled', [
+ 'type' => self::TYPE_BOOLEAN,
+ 'description' => 'Service status.',
+ 'example' => false,
+ 'default' => true,
+ ])
+ ;
+ }
+
+ /**
+ * Get Name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return 'ProjectService';
+ }
+
+ /**
+ * Get Type
+ *
+ * @return string
+ */
+ public function getType(): string
+ {
+ return Response::MODEL_PROJECT_SERVICE;
+ }
+}
diff --git a/src/Appwrite/Utopia/Response/Model/Report.php b/src/Appwrite/Utopia/Response/Model/Report.php
new file mode 100644
index 0000000000..0c5baf9cdd
--- /dev/null
+++ b/src/Appwrite/Utopia/Response/Model/Report.php
@@ -0,0 +1,99 @@
+addRule('$id', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Report ID.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('$createdAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Report creation date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('$updatedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Report update date in ISO 8601 format.',
+ 'default' => '',
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ ])
+ ->addRule('appId', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'ID of the third-party app that submitted the report.',
+ 'default' => '',
+ 'example' => '5e5ea5c16897e',
+ ])
+ ->addRule('type', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Analyzer that produced this report. e.g. lighthouse, audit, databaseAnalyzer.',
+ 'default' => '',
+ 'example' => 'lighthouse',
+ ])
+ ->addRule('title', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Short, human-readable title for the report.',
+ 'default' => '',
+ 'example' => 'Lighthouse audit for https://appwrite.io/',
+ ])
+ ->addRule('summary', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Markdown summary describing the report.',
+ 'default' => '',
+ 'example' => 'Performance score 78. 4 opportunities found.',
+ ])
+ ->addRule('targetType', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Plural noun describing what the report analyzes, e.g. databases, sites, urls.',
+ 'default' => '',
+ 'example' => 'urls',
+ ])
+ ->addRule('target', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Free-form target identifier (URL for lighthouse, resource ID for db).',
+ 'default' => '',
+ 'example' => 'https://appwrite.io/',
+ ])
+ ->addRule('categories', [
+ 'type' => self::TYPE_STRING,
+ 'description' => 'Categories covered by the report, e.g. performance, accessibility.',
+ 'default' => [],
+ 'example' => ['performance', 'accessibility'],
+ 'array' => true,
+ ])
+ ->addRule('insights', [
+ 'type' => Response::MODEL_INSIGHT,
+ 'description' => 'Insights nested under this report.',
+ 'default' => [],
+ 'example' => [],
+ 'array' => true,
+ ])
+ ->addRule('analyzedAt', [
+ 'type' => self::TYPE_DATETIME,
+ 'description' => 'Time the report was analyzed in ISO 8601 format.',
+ 'default' => null,
+ 'example' => self::TYPE_DATETIME_EXAMPLE,
+ 'required' => false,
+ ]);
+ }
+
+ public function getName(): string
+ {
+ return 'Report';
+ }
+
+ public function getType(): string
+ {
+ return Response::MODEL_REPORT;
+ }
+}
diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 4dc0174e50..8741ecff6c 100644
--- a/src/Appwrite/Vcs/Comment.php
+++ b/src/Appwrite/Vcs/Comment.php
@@ -50,6 +50,8 @@ class Comment
protected string $statePrefix = '[appwrite]: #';
+ protected ?string $tip = null;
+
/**
* @var mixed[] $builds
*/
@@ -81,7 +83,14 @@ class Comment
public function generateComment(): string
{
- $json = \json_encode($this->builds);
+ if ($this->tip === null) {
+ $this->tip = $this->tips[\array_rand($this->tips)];
+ }
+
+ $json = \json_encode([
+ 'builds' => $this->builds,
+ 'tip' => $this->tip,
+ ]);
$text = $this->statePrefix . \base64_encode($json) . "\n\n";
@@ -226,8 +235,7 @@ class Comment
$i++;
}
- $tip = $this->tips[array_rand($this->tips)];
- $text .= "\n
\n\n> [!TIP]\n> $tip\n\n";
+ $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n";
return $text;
}
@@ -252,8 +260,15 @@ class Comment
$json = \base64_decode($state);
- $builds = \json_decode($json, true);
- $this->builds = \is_array($builds) ? $builds : [];
+ $data = \json_decode($json, true);
+
+ if (\is_array($data) && \array_key_exists('builds', $data)) {
+ $this->builds = \is_array($data['builds']) ? $data['builds'] : [];
+ $this->tip = $data['tip'] ?? null;
+ } else {
+ // Backward compatibility with old state format (builds array only)
+ $this->builds = \is_array($data) ? $data : [];
+ }
return $this;
}
diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php
index 99219ebf99..c34a6527f9 100644
--- a/tests/e2e/Scopes/ProjectCustom.php
+++ b/tests/e2e/Scopes/ProjectCustom.php
@@ -177,6 +177,10 @@ trait ProjectCustom
'project.policies.write',
'templates.read',
'templates.write',
+ 'insights.read',
+ 'insights.write',
+ 'reports.read',
+ 'reports.write',
],
]);
diff --git a/tests/e2e/Services/Advisor/AdvisorBase.php b/tests/e2e/Services/Advisor/AdvisorBase.php
new file mode 100644
index 0000000000..f228cf5591
--- /dev/null
+++ b/tests/e2e/Services/Advisor/AdvisorBase.php
@@ -0,0 +1,122 @@
+ 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ }
+
+ protected function getReport(string $reportId, ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId, $headers ?? $this->serverHeaders());
+ }
+
+ protected function listReports(array $params = [], ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports', $headers ?? $this->serverHeaders(), $params);
+ }
+
+ protected function getInsight(string $reportId, string $insightId, ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights/' . $insightId, $headers ?? $this->serverHeaders());
+ }
+
+ protected function listInsights(string $reportId, array $params = [], ?array $headers = null): array
+ {
+ return $this->client->call(Client::METHOD_GET, '/reports/' . $reportId . '/insights', $headers ?? $this->serverHeaders(), $params);
+ }
+
+ public function testListReports(): void
+ {
+ $list = $this->listReports();
+
+ $this->assertSame(200, $list['headers']['status-code']);
+ $this->assertArrayHasKey('reports', $list['body']);
+ $this->assertArrayHasKey('total', $list['body']);
+ $this->assertIsArray($list['body']['reports']);
+ }
+
+ public function testGetReportMissing(): void
+ {
+ $missing = $this->getReport(ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testListInsightsMissingReport(): void
+ {
+ $missing = $this->listInsights(ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testGetInsightMissingReport(): void
+ {
+ $missing = $this->getInsight(ID::unique(), ID::unique());
+
+ $this->assertSame(404, $missing['headers']['status-code']);
+ $this->assertSame('report_not_found', $missing['body']['type']);
+ }
+
+ public function testReportsCreateAndUpdateNotExposed(): void
+ {
+ $create = $this->client->call(Client::METHOD_POST, '/reports', $this->serverHeaders(), [
+ 'reportId' => ID::unique(),
+ 'type' => 'audit',
+ 'title' => 'Read-only check',
+ 'targetType' => 'sites',
+ 'target' => 'home',
+ ]);
+ $this->assertSame(404, $create['headers']['status-code']);
+
+ $update = $this->client->call(Client::METHOD_PATCH, '/reports/' . ID::unique(), $this->serverHeaders(), [
+ 'title' => 'Read-only check',
+ ]);
+ $this->assertSame(404, $update['headers']['status-code']);
+ }
+
+ public function testDeleteReportMissing(): void
+ {
+ $delete = $this->client->call(Client::METHOD_DELETE, '/reports/' . ID::unique(), $this->serverHeaders());
+ $this->assertSame(404, $delete['headers']['status-code']);
+ $this->assertSame('report_not_found', $delete['body']['type']);
+ }
+
+ public function testInsightsCreateUpdateDeleteNotExposed(): void
+ {
+ $create = $this->client->call(
+ Client::METHOD_POST,
+ '/reports/' . ID::unique() . '/insights',
+ $this->serverHeaders(),
+ []
+ );
+ $this->assertSame(404, $create['headers']['status-code']);
+
+ $update = $this->client->call(
+ Client::METHOD_PATCH,
+ '/reports/' . ID::unique() . '/insights/' . ID::unique(),
+ $this->serverHeaders(),
+ ['status' => 'dismissed']
+ );
+ $this->assertSame(404, $update['headers']['status-code']);
+
+ $delete = $this->client->call(
+ Client::METHOD_DELETE,
+ '/reports/' . ID::unique() . '/insights/' . ID::unique(),
+ $this->serverHeaders()
+ );
+ $this->assertSame(404, $delete['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php
new file mode 100644
index 0000000000..d91f95035e
--- /dev/null
+++ b/tests/e2e/Services/Advisor/AdvisorCustomServerTest.php
@@ -0,0 +1,58 @@
+getProject()['$id'];
+
+ $userKey = $this->getNewKey([
+ // Advisor read APIs are protected by the underlying report/insight resource scopes.
+ 'insights.read',
+ 'reports.read',
+ ]);
+
+ $listed = $this->client->call(
+ Client::METHOD_GET,
+ '/reports',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $userKey,
+ ]
+ );
+
+ $this->assertSame(200, $listed['headers']['status-code']);
+
+ $create = $this->client->call(
+ Client::METHOD_POST,
+ '/reports',
+ [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'x-appwrite-key' => $userKey,
+ ],
+ [
+ 'reportId' => ID::unique(),
+ 'type' => 'audit',
+ 'title' => 'Read-only check',
+ 'targetType' => 'sites',
+ 'target' => 'home',
+ ]
+ );
+
+ $this->assertSame(404, $create['headers']['status-code']);
+ }
+}
diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php
index 14442abb60..614349ae74 100644
--- a/tests/e2e/Services/Avatars/AvatarsBase.php
+++ b/tests/e2e/Services/Avatars/AvatarsBase.php
@@ -204,7 +204,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
]);
$this->assertEquals(200, $response['headers']['status-code']);
@@ -216,7 +216,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 200,
'height' => 200,
]);
@@ -230,7 +230,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 300,
'height' => 300,
'quality' => 30,
@@ -258,7 +258,7 @@ trait AvatarsBase
$response = $this->client->call(Client::METHOD_GET, '/avatars/image', [
'x-appwrite-project' => $this->getProject()['$id'],
], [
- 'url' => 'https://appwrite.io/images/open-graph/website.png',
+ 'url' => 'https://appwrite.io/images/open-graph/website.avif',
'width' => 2001,
'height' => 300,
'quality' => 30,
diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
index abe4d4968b..a7cad1c0f7 100644
--- a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class DatabasesConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class DatabasesConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
index 80c2bc9d5c..238d197158 100644
--- a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
+++ b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php
@@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
-use Utopia\Database\Query;
class VectorsDBConsoleClientTest extends Scope
{
@@ -258,55 +257,4 @@ class VectorsDBConsoleClientTest extends Scope
$this->assertIsArray($response['body']['documents']);
}
- #[Depends('testCreateCollection')]
- public function testGetCollectionLogs(array $data)
- {
- $databaseId = $data['databaseId'];
- /**
- * Test for SUCCESS
- */
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertIsNumeric($logs['body']['total']);
-
- $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()]
- ]);
-
- $this->assertEquals(200, $logs['headers']['status-code']);
- $this->assertIsArray($logs['body']['logs']);
- $this->assertLessThanOrEqual(1, count($logs['body']['logs']));
- $this->assertIsNumeric($logs['body']['total']);
- }
}
diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php
index c42679018e..9010490cc9 100644
--- a/tests/e2e/Services/GraphQL/Base.php
+++ b/tests/e2e/Services/GraphQL/Base.php
@@ -4,6 +4,7 @@ namespace Tests\E2E\Services\GraphQL;
use CURLFile;
use Utopia\Console;
+use Utopia\Image\Image;
trait Base
{
@@ -516,6 +517,21 @@ trait Base
}
';
+ protected function assertFilePreviewResponse(array $file): void
+ {
+ $this->assertEquals(200, $file['headers']['status-code']);
+ $this->assertEquals('image/png', $file['headers']['content-type']);
+ $this->assertNotEmpty($file['body']);
+
+ $image = new Image($file['body']);
+ $dimensions = \getimagesizefromstring($file['body']);
+
+ $this->assertNotEmpty($image->output('png'));
+ $this->assertIsArray($dimensions);
+ $this->assertEquals(100, $dimensions[0]);
+ $this->assertEquals(100, $dimensions[1]);
+ }
+
public function getQuery(string $name): string
{
switch ($name) {
@@ -2388,8 +2404,8 @@ trait Base
}
}';
case self::GET_FILE_PREVIEW:
- return 'query getFilePreview($bucketId: String!, $fileId: String!) {
- storageGetFilePreview(bucketId: $bucketId, fileId: $fileId) {
+ return 'query getFilePreview($bucketId: String!, $fileId: String!, $width: Int, $height: Int) {
+ storageGetFilePreview(bucketId: $bucketId, fileId: $fileId, width: $width, height: $height) {
status
}
}';
diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php
index dd89819c34..9cdf523a0a 100644
--- a/tests/e2e/Services/GraphQL/StorageClientTest.php
+++ b/tests/e2e/Services/GraphQL/StorageClientTest.php
@@ -200,7 +200,7 @@ class StorageClientTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php
index 1377ef9207..7808c50be6 100644
--- a/tests/e2e/Services/GraphQL/StorageServerTest.php
+++ b/tests/e2e/Services/GraphQL/StorageServerTest.php
@@ -262,7 +262,7 @@ class StorageServerTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/Messaging/MessagingBase.php b/tests/e2e/Services/Messaging/MessagingBase.php
index d83b450739..43dc58d44c 100644
--- a/tests/e2e/Services/Messaging/MessagingBase.php
+++ b/tests/e2e/Services/Messaging/MessagingBase.php
@@ -2525,6 +2525,119 @@ trait MessagingBase
$this->assertEquals(0, \count($message['body']['deliveryErrors']));
}
+ public function testCreatePushNotificationWithUsersRecipients(): void
+ {
+ if (empty(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'))) {
+ $this->markTestSkipped('Push DSN empty');
+ }
+
+ $dsn = new DSN(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'));
+ $to = $dsn->getParam('to');
+ $serviceAccountJSON = $dsn->getParam('serviceAccountJSON');
+
+ if (empty($to) || empty($serviceAccountJSON)) {
+ $this->markTestSkipped('Push provider not configured');
+ }
+
+ $provider1 = $this->client->call(Client::METHOD_POST, '/messaging/providers/fcm', \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'providerId' => ID::unique(),
+ 'name' => 'FCM-Users-1',
+ 'serviceAccountJSON' => $serviceAccountJSON,
+ 'enabled' => true,
+ ]);
+
+ $this->assertEquals(201, $provider1['headers']['status-code']);
+
+ $provider2 = $this->client->call(Client::METHOD_POST, '/messaging/providers/fcm', \array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]), [
+ 'providerId' => ID::unique(),
+ 'name' => 'FCM-Users-2',
+ 'serviceAccountJSON' => $serviceAccountJSON,
+ 'enabled' => true,
+ ]);
+
+ $this->assertEquals(201, $provider2['headers']['status-code']);
+
+ $user = $this->client->call(Client::METHOD_POST, '/users', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'userId' => ID::unique(),
+ 'email' => uniqid() . "@mail.org",
+ 'password' => 'password',
+ 'name' => 'Messaging User Recipients',
+ ]);
+
+ $this->assertEquals(201, $user['headers']['status-code']);
+
+ $target1 = $this->client->call(Client::METHOD_POST, '/users/' . $user['body']['$id'] . '/targets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'targetId' => ID::unique(),
+ 'providerType' => 'push',
+ 'providerId' => $provider1['body']['$id'],
+ 'identifier' => $to,
+ ]);
+
+ $this->assertEquals(201, $target1['headers']['status-code']);
+
+ $target2 = $this->client->call(Client::METHOD_POST, '/users/' . $user['body']['$id'] . '/targets', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'targetId' => ID::unique(),
+ 'providerType' => 'push',
+ 'providerId' => $provider2['body']['$id'],
+ 'identifier' => $to,
+ ]);
+
+ $this->assertEquals(201, $target2['headers']['status-code']);
+
+ $push = $this->client->call(Client::METHOD_POST, '/messaging/messages/push', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'messageId' => ID::unique(),
+ 'users' => [$user['body']['$id']],
+ 'title' => 'Test-Notification-Users',
+ 'body' => 'Test-Notification-Body-Users',
+ ]);
+
+ $this->assertEquals(201, $push['headers']['status-code']);
+
+ $pushMessageId = $push['body']['$id'];
+ $this->assertEventually(function () use ($pushMessageId) {
+ $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $pushMessageId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertContains($response['body']['status'], ['sent', 'failed']);
+ }, 30000, 500);
+
+ $message = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $pushMessageId, [
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ]);
+
+ $this->assertEquals(200, $message['headers']['status-code']);
+ $this->assertEquals(2, $message['body']['deliveredTotal'] + \count($message['body']['deliveryErrors']));
+ }
+
public function testUpdatePushNotification(): void
{
$push = $this->setupSentPushData();
diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php
index 4212edb9df..8dd5b2fef6 100644
--- a/tests/e2e/Services/Migrations/MigrationsBase.php
+++ b/tests/e2e/Services/Migrations/MigrationsBase.php
@@ -761,6 +761,1275 @@ trait MigrationsBase
self::$cachedTableData = [];
}
+ /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */
+ public function testAppwriteMigrationRowsOnDuplicate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'Original'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ $rowId = $row['body']['$id'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: destination is empty, strict completion expected.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Mutate destination row to prove onDuplicate=skip preserves it.
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [
+ 'data' => ['name' => 'Mutated'],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals('Mutated', $mutate['body']['name']);
+
+ // Re-migration with onDuplicate=skip â completion is strict because
+ // DestinationAppwrite tolerates existing schema resources.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterSkip['headers']['status-code']);
+ $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row');
+
+ // Re-migration with onDuplicate=overwrite â strict completion; destination
+ // row restored to source value.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']);
+ $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Unchanged source under Skip/Overwrite is a no-op â every resource Tolerated. */
+ public function testAppwriteMigrationReRunIsIdempotent(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ // Seed two rows on source so the row-level tolerance is exercised too.
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'Seeded ' . $rowId],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ }
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: fresh destination.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Re-run under Skip: nothing on source has changed. Destination
+ // schema + rows are already correct â expect clean completion.
+ $reRunSkip = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $reRunSkip['status']);
+
+ // Re-run under Overwrite: same unchanged source. Schema tolerance path
+ // fires for each resource; rows go through DB-native upsert.
+ $reRunOverwrite = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $reRunOverwrite['status']);
+
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $check['headers']['status-code']);
+ $this->assertEquals('Seeded ' . $rowId, $check['body']['name']);
+ }
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-me';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration â dest empty, strict completion.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // `_updatedAt` is stored at second granularity (strtotime) â ensure
+ // the source edits below produce a strictly-newer timestamp than
+ // dest's first-migration timestamp.
+ sleep(1);
+
+ // Mutate source: rename database + toggle table enabled.
+ $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [
+ 'name' => 'Renamed Source DB',
+ ]);
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Renamed Source Table',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Overwrite re-migration: UpdateInPlace path fires for database + table.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Assert dest database metadata reflects source's new values.
+ $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders);
+ $this->assertEquals(200, $destDb['headers']['status-code']);
+ $this->assertEquals('Renamed Source DB', $destDb['body']['name']);
+
+ // Assert dest table metadata reflects source's new values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Renamed Source Table', $destTable['body']['name']);
+ $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false');
+ $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true');
+
+ // Child row untouched â UpdateInPlace only rewrites container metadata.
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest container drift even when source has diverged. */
+ public function testAppwriteMigrationSkipPreservesContainerDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ // First migration: dest gets whatever source had.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Mutate dest: ops tightens permissions and renames the table for
+ // its production-specific branding.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [
+ 'name' => 'Dest-Managed Table',
+ 'permissions' => [Permission::read(Role::users())],
+ 'rowSecurity' => false,
+ 'enabled' => true,
+ ]);
+
+ // Also mutate source so the second run has a real divergence.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Source Renamed',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Skip re-migration: must tolerate existing destination â no update.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ // Dest kept its tightened values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift');
+ $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */
+ public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: dest mirrors source (one column 'name').
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Add an orphan column directly on destination (not on source).
+ // Simulates the post-rename state: source dropped a column, dest
+ // still has it â or a dest-only column added by a separate app.
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'orphan_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ // Seed a row on source so per-table orphan cleanup fires inside
+ // createRecord (before rows land), not just at end of run.
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Overwrite re-migration: orphan_col must be dropped from dest.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Orphan column dropped.
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares');
+
+ // Source's column preserved.
+ $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves orphan columns; cleanup is Overwrite-only. */
+ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'dest_only_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Skip re-migration: orphan column must NOT be dropped.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-on-inplace';
+
+ // Seed a row that proves drop+recreate didn't happen â recreate would
+ // have wiped this column's data on the destination.
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration â dest gets the column as required:true.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $beforeUpdate['headers']['status-code']);
+ $this->assertTrue($beforeUpdate['body']['required']);
+
+ // _updatedAt has second granularity; ensure source's PATCH produces a
+ // strictly-newer timestamp than the dest's first-migration value.
+ sleep(1);
+
+ // SDK-reachable change set: required trueâfalse, default nullâ'unknown'.
+ // Both fields are supported by PATCH /columns/string/:key â must route
+ // through updateAttributeInPlace, not DropAndRecreate.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => false,
+ 'default' => 'unknown',
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ $this->assertEquals('unknown', $r['body']['default']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false');
+ $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default');
+ }, 10000, 500);
+
+ // Pre-existing row preserved â proof that the path was UpdateInPlace
+ // and not DropAndRecreate (which would have nulled this column).
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */
+ public function testAppwriteMigrationSkipPreservesAttributeDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Dest divergence: ops loosens the column for a production-only need.
+ $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [
+ 'required' => false,
+ 'default' => 'dest-default',
+ ]);
+ $this->assertEquals(200, $destPatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ }, 5000, 500);
+
+ sleep(1);
+
+ // Source advances strictly later (and to a different value). Under
+ // Overwrite this would propagate to dest; under Skip it must not.
+ $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => true,
+ 'default' => null,
+ ]);
+ $this->assertEquals(200, $sourcePatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertTrue($r['body']['required']);
+ }, 5000, 500);
+
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAttr['headers']['status-code']);
+ $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift');
+ $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */
+ public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Rel In-Place DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Two-way: parents.kids â children.parent. Required to hit the in-place path.
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Both sides land on dest with onDelete=cascade.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Both sides on dest must reflect onDelete=restrict. Asserting the
+ // partner side is the regression guard for the previously-missed
+ // partner meta refresh in updateRelationshipInPlace.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType');
+ $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay');
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */
+ public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Two-Way Recreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ 'permissions' => [
+ Permission::create(Role::any()),
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Add a non-relationship column on parents so we can POST a row with
+ // non-empty data. tablesdb POST /rows rejects empty data arrays in
+ // 1.9.x (Create.php:161 â getSupportForEmptyDocument() defaults false).
+ $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [
+ 'key' => 'label',
+ 'size' => 32,
+ 'required' => false,
+ ]);
+ $this->assertEquals(202, $createLabel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [
+ 'rowId' => 'parent-1',
+ 'data' => ['label' => 'p1'],
+ ]);
+ $this->assertEquals(201, $parentRow['headers']['status-code']);
+ $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [
+ 'rowId' => 'child-1',
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(201, $childRow['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Recreate the relationship on source so its createdAt advances past
+ // dest's stored value â forces SchemaAction::DropAndRecreate on the
+ // parent side, which is the path the partner-side dedup guards.
+ sleep(1);
+ $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(204, $deleteRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ sleep(1);
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Child-row's relationship was wiped by the source-side delete. Re-link.
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ }, 10000, 500);
+
+ // Both rows survive the re-migration. If the partner-side dedup were
+ // missing and the partner pass re-fired DropAndRecreate, the partner
+ // (children) table's row would have been wiped before the row pass.
+ $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders);
+ $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration');
+ $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */
+ public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'One-Way DropAndRecreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => false,
+ 'key' => 'kids',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType');
+ $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails â drop+recreate; row pass refills. */
+ public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-after-recreate';
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Drop + recreate the column on source. createdAt advances â re-migration
+ // must take the createdAt-diff DropAndRecreate path on dest.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ // Recreate with `array: true` â a non-SDK change (`array` is in
+ // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail
+ // and the caller to fall through to drop+recreate, which is what
+ // this test pins.
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => false,
+ 'array' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now).
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => ['after-recreate']],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $col['headers']['status-code']);
+ $this->assertEquals('available', $col['body']['status']);
+ $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)');
+ $this->assertFalse($col['body']['required']);
+ }, 10000, 500);
+
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */
+ public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-spec-match';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destBefore['headers']['status-code']);
+ $destCreatedAtBefore = $destBefore['body']['$createdAt'];
+
+ sleep(1);
+
+ // Drop + recreate with the EXACT same spec as setupMigrationTable
+ // (size=100, required=true). Source's $createdAt advances but the
+ // spec is identical â spec-match guard must force Tolerate.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => 'after-recreate'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Spec-match guard fired â dest column's $createdAt stayed at the
+ // first-migration value. If DropAndRecreate had run, $createdAt
+ // would have been bumped to source's NEW createdAt.
+ $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAfter['headers']['status-code']);
+ $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched');
+ $this->assertEquals(100, $destAfter['body']['size']);
+ $this->assertTrue($destAfter['body']['required']);
+
+ // Row pass under Overwrite still propagated source's new row value.
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('after-recreate', $rowAfter['body']['name']);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
/**
* Storage
*/
@@ -1483,6 +2752,260 @@ trait MigrationsBase
}, 10_000, 500);
}
+ /**
+ * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests.
+ * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge].
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareCsvImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.csv fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ // Columns are created async (202). Wait for both to be `available`
+ // before proceeding so the migration worker doesn't race the schema.
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['csv'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.csv (100 rows with $id, name, age columns)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged.
+ */
+ public function testCreateCSVImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=skip: no errors, mutated row preserved
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row kept its mutated value (not overwritten by CSV's original age)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on re-import: existing rows are replaced with imported values.
+ */
+ public function testCreateCSVImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove overwrite restores it to the CSV's original value
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=overwrite: mutated row restored to CSV value
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row is back to CSV's original age (proving overwrite actually replaced the row)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException.
+ * Regression guard so the skip/overwrite additions don't silently change the default.
+ */
+ public function testCreateCSVImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default');
+
+ // First import: succeeds
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Second import with no flags: should fail on duplicate ids
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
private function performCsvMigration(array $body): array
{
return $this->client->call(Client::METHOD_POST, '/migrations/csv', [
@@ -1492,6 +3015,246 @@ trait MigrationsBase
], $body);
}
+ /**
+ * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests.
+ * Mirrors prepareCsvImportFixture but uploads documents.json instead.
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareJsonImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test JSON DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test JSON table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.json fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'JSON Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['json'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.json (same row shape as documents.csv)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged.
+ */
+ public function testCreateJSONImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values.
+ */
+ public function testCreateJSONImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids.
+ */
+ public function testCreateJSONImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
/**
* Test CSV export with email notification
*/
diff --git a/tests/e2e/Services/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php
index afa58a3640..cccdca9ea1 100644
--- a/tests/e2e/Services/Project/AuthMethodsBase.php
+++ b/tests/e2e/Services/Project/AuthMethodsBase.php
@@ -212,6 +212,7 @@ trait AuthMethodsBase
$headers = \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders());
$projectId = $this->getProject()['$id'];
@@ -322,7 +323,9 @@ trait AuthMethodsBase
];
if ($authenticated) {
- $headers = \array_merge($headers, $this->getHeaders());
+ $headers = \array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
return $this->client->call(
diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php
index 47d92a2a58..5959a584ea 100644
--- a/tests/e2e/Services/Project/OAuth2Base.php
+++ b/tests/e2e/Services/Project/OAuth2Base.php
@@ -2564,6 +2564,159 @@ trait OAuth2Base
]);
}
+ // =========================================================================
+ // Update Google (clientId + clientSecret + optional prompt)
+ // =========================================================================
+
+ /**
+ * Default prompt MUST run before any other Google test that sets a custom
+ * prompt value. The global resetProjectOAuth2() only clears Amazon state,
+ * so Google state leaks across tests in the same class. Running this first
+ * guarantees the stored JSON blob has no pre-existing "prompt" key.
+ */
+ public function testUpdateOAuth2GoogleDefaultPrompt(): void
+ {
+ // When prompt is omitted and nothing is stored, the default is ['consent'].
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-default-client',
+ 'clientSecret' => 'google-default-secret',
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['consent'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2Google(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['select_account'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google', $response['body']['$id']);
+ $this->assertSame('120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', $response['body']['clientId']);
+ $this->assertSame(['select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePartialPreservesPrompt(): void
+ {
+ // Seed clientSecret + prompt.
+ $this->updateOAuth2('google', [
+ 'clientId' => 'google-seed-client',
+ 'clientSecret' => 'google-seed-secret',
+ 'prompt' => ['consent', 'select_account'],
+ 'enabled' => false,
+ ]);
+
+ // Update only clientId.
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'google-rotated-client',
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('google-rotated-client', $response['body']['clientId']);
+ $this->assertSame(['consent', 'select_account'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => ['none', 'consent'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptEmptyArrayRejected(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => 'whatever',
+ 'clientSecret' => 'whatever',
+ 'prompt' => [],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(400, $response['headers']['status-code']);
+ $this->assertSame('general_argument_invalid', $response['body']['type']);
+ }
+
+ public function testUpdateOAuth2GooglePromptNoneAloneAccepted(): void
+ {
+ $response = $this->updateOAuth2('google', [
+ 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com',
+ 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj',
+ 'prompt' => ['none'],
+ 'enabled' => false,
+ ]);
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame(['none'], $response['body']['prompt']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
+ public function testUpdateOAuth2GoogleEnableAndReadBack(): void
+ {
+ $update = $this->updateOAuth2('google', [
+ 'clientId' => 'google-enable-client',
+ 'clientSecret' => 'google-enable-secret',
+ 'prompt' => ['select_account'],
+ 'enabled' => true,
+ ]);
+
+ $this->assertSame(200, $update['headers']['status-code']);
+ $this->assertTrue($update['body']['enabled']);
+
+ // GET must hide clientSecret while keeping clientId and prompt.
+ $get = $this->getOAuth2Provider('google');
+ $this->assertSame(200, $get['headers']['status-code']);
+ $this->assertTrue($get['body']['enabled']);
+ $this->assertSame('google-enable-client', $get['body']['clientId']);
+ $this->assertSame(['select_account'], $get['body']['prompt']);
+ $this->assertSame('', $get['body']['clientSecret']);
+
+ // Cleanup
+ $this->updateOAuth2('google', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'enabled' => false,
+ ]);
+ }
+
// =========================================================================
// Smoke test: every plain (clientId + clientSecret) provider
//
diff --git a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php
index f86557a432..7b0479ea09 100644
--- a/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php
+++ b/tests/e2e/Services/Project/OAuthGitHubIntegrationTest.php
@@ -28,6 +28,7 @@ class OAuthGitHubIntegrationTest extends Scope
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
];
// Step 1: Create new organization (team)
diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php
index 04906c6c2b..43c09b55c3 100644
--- a/tests/e2e/Services/Project/PoliciesBase.php
+++ b/tests/e2e/Services/Project/PoliciesBase.php
@@ -1082,7 +1082,9 @@ trait PoliciesBase
];
if ($authenticated) {
- $headers = array_merge($headers, $this->getHeaders());
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
return $headers;
@@ -1094,6 +1096,7 @@ trait PoliciesBase
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
+ 'x-appwrite-response-format' => '1.9.4',
]);
}
diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php
index 378cd1800d..95d96c49e2 100644
--- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
// Step 1: Configure privacy to false
diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
index 2d0e15a70f..0da64eb50b 100644
--- a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesPasswordDictionaryIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
// "password" is the top entry in the common-passwords dictionary and is 8 chars (min length).
diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
index c2dfd7be5e..1027d88222 100644
--- a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesPasswordHistoryIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
// Step 1: Enable password history policy with limit 3
diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
index 3284fed16f..ebb94a2631 100644
--- a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php
@@ -21,6 +21,7 @@ class PoliciesPasswordPersonalDataIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
$setPersonalData = function (bool $enabled) use ($serverHeaders): void {
diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
index 1500a1dcfa..3e847b9ddb 100644
--- a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php
@@ -23,6 +23,7 @@ class PoliciesSessionAlertIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
$publicHeaders = [
diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
index 71562f52a5..582da65373 100644
--- a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesSessionDurationIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
$publicHeaders = [
diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
index c9de2be9a5..b6f972ca3e 100644
--- a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesSessionInvalidationIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
$publicHeaders = [
diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
index 5ddcd8aaa1..6025d80536 100644
--- a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
+++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php
@@ -22,6 +22,7 @@ class PoliciesUserLimitIntegrationTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
+ 'x-appwrite-response-format' => '1.9.4',
];
$signupHeaders = [
diff --git a/tests/e2e/Services/Project/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php
index 0ba69c21b6..a4c8b73efc 100644
--- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php
+++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php
@@ -51,6 +51,88 @@ class ProjectConsoleClientTest extends Scope
$this->assertSame(404, $getProject['headers']['status-code']);
}
+ public function testGetProject(): void
+ {
+ $team = $this->createTeam('Get Project Team');
+ $project = $this->createProject($team['body']['$id'], 'Get Project');
+
+ $response = $this->client->call(Client::METHOD_GET, '/project', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $project['body']['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame($project['body']['$id'], $response['body']['$id']);
+ $this->assertNotEmpty($response['body']['$createdAt']);
+ $this->assertNotEmpty($response['body']['$updatedAt']);
+ $this->assertSame('Get Project', $response['body']['name']);
+ $this->assertSame($team['body']['$id'], $response['body']['teamId']);
+ $this->assertSame('active', $response['body']['status']);
+
+ // Auth methods
+ $this->assertIsArray($response['body']['authMethods']);
+ $this->assertNotEmpty($response['body']['authMethods']);
+ foreach ($response['body']['authMethods'] as $authMethod) {
+ $this->assertArrayHasKey('$id', $authMethod);
+ $this->assertArrayHasKey('enabled', $authMethod);
+ $this->assertIsBool($authMethod['enabled']);
+ }
+
+ // Services
+ $this->assertIsArray($response['body']['services']);
+ $this->assertNotEmpty($response['body']['services']);
+ foreach ($response['body']['services'] as $service) {
+ $this->assertArrayHasKey('$id', $service);
+ $this->assertArrayHasKey('enabled', $service);
+ $this->assertIsBool($service['enabled']);
+ }
+
+ // Protocols
+ $this->assertIsArray($response['body']['protocols']);
+ $this->assertNotEmpty($response['body']['protocols']);
+ foreach ($response['body']['protocols'] as $protocol) {
+ $this->assertArrayHasKey('$id', $protocol);
+ $this->assertArrayHasKey('enabled', $protocol);
+ $this->assertIsBool($protocol['enabled']);
+ }
+
+ // SMTP defaults
+ $this->assertFalse($response['body']['smtpEnabled']);
+ $this->assertSame('', $response['body']['smtpSenderEmail']);
+ $this->assertSame('', $response['body']['smtpSenderName']);
+ $this->assertSame('', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('', $response['body']['smtpReplyToName']);
+ $this->assertSame('', $response['body']['smtpHost']);
+ $this->assertSame('', $response['body']['smtpPort']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']);
+ $this->assertSame('', $response['body']['smtpSecure']);
+
+ // Other fields
+ $this->assertIsArray($response['body']['labels']);
+ $this->assertIsArray($response['body']['devKeys']);
+ $this->assertSame(0, $response['body']['pingCount']);
+ $this->assertSame('', $response['body']['pingedAt']);
+
+ // Ensure old flattened fields are not present
+ $this->assertArrayNotHasKey('description', $response['body']);
+ $this->assertArrayNotHasKey('logo', $response['body']);
+ $this->assertArrayNotHasKey('url', $response['body']);
+ $this->assertArrayNotHasKey('authDuration', $response['body']);
+ $this->assertArrayNotHasKey('authLimit', $response['body']);
+ $this->assertArrayNotHasKey('authSessionsLimit', $response['body']);
+ $this->assertArrayNotHasKey('authPasswordHistory', $response['body']);
+ $this->assertArrayNotHasKey('authPasswordDictionary', $response['body']);
+ $this->assertArrayNotHasKey('authPersonalDataCheck', $response['body']);
+ $this->assertArrayNotHasKey('authDisposableEmails', $response['body']);
+ $this->assertArrayNotHasKey('authCanonicalEmails', $response['body']);
+ $this->assertArrayNotHasKey('authFreeEmails', $response['body']);
+ $this->assertArrayNotHasKey('oAuthProviders', $response['body']);
+ $this->assertArrayNotHasKey('platforms', $response['body']);
+ $this->assertArrayNotHasKey('webhooks', $response['body']);
+ $this->assertArrayNotHasKey('keys', $response['body']);
+ }
+
protected function createTeam(string $name): array
{
$response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [
diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php
index f828994ea3..092ed97b6a 100644
--- a/tests/e2e/Services/Project/ProtocolsBase.php
+++ b/tests/e2e/Services/Project/ProtocolsBase.php
@@ -248,6 +248,7 @@ trait ProtocolsBase
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders());
// Disable via the legacy `/status` alias
@@ -278,7 +279,9 @@ trait ProtocolsBase
];
if ($authenticated) {
- $headers = array_merge($headers, $this->getHeaders());
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [
diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php
index b5f94f8181..0c8b337315 100644
--- a/tests/e2e/Services/Project/ServicesBase.php
+++ b/tests/e2e/Services/Project/ServicesBase.php
@@ -246,6 +246,7 @@ trait ServicesBase
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders());
// Disable via the legacy `/status` alias
@@ -276,7 +277,9 @@ trait ServicesBase
];
if ($authenticated) {
- $headers = array_merge($headers, $this->getHeaders());
+ $headers = array_merge($headers, $this->getHeaders(), [
+ 'x-appwrite-response-format' => '1.9.4',
+ ]);
}
return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [
diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
index 3a9037c368..aa5e6911f1 100644
--- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
+++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
@@ -46,6 +46,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4'
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -68,6 +69,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -89,6 +91,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => '',
@@ -101,6 +104,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -127,6 +131,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'MultiDB Project',
@@ -210,6 +215,7 @@ class ProjectsConsoleClientTest extends Scope
$getProject = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $getProject['headers']['status-code']);
@@ -234,6 +240,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => $projectId,
'name' => 'Original Project',
@@ -249,6 +256,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => $projectId,
'name' => 'Project Duplicate',
@@ -298,6 +306,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Team 1 Project',
@@ -318,6 +327,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/team', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'teamId' => $team2,
]);
@@ -341,6 +351,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -353,6 +364,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders(), [
'search' => $id
]));
@@ -364,6 +376,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders(), [
'search' => 'Project Test'
]));
@@ -390,6 +403,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -408,6 +422,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::equal('teamId', [$team['body']['$id']])->toString(),
@@ -422,6 +437,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::limit(1)->toString(),
@@ -435,6 +451,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::offset(1)->toString(),
@@ -447,6 +464,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::equal('name', ['Project Test 2'])->toString(),
@@ -461,6 +479,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::orderDesc()->toString(),
@@ -474,6 +493,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -483,6 +503,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => $response['body']['projects'][0]['$id']]))->toString(),
@@ -498,6 +519,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::cursorAfter(new Document(['$id' => 'unknown']))->toString(),
@@ -524,6 +546,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Query Select Test Project',
@@ -540,6 +563,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name'])->toString(),
@@ -569,6 +593,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4'
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'teamId', 'description', '$createdAt', '$updatedAt'])->toString(),
@@ -600,6 +625,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'teamId'])->toString(),
@@ -631,6 +657,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name'])->toString(),
@@ -661,6 +688,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'platforms'])->toString(),
@@ -690,6 +718,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'name', 'webhooks', 'keys'])->toString(),
@@ -719,6 +748,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['*'])->toString(),
@@ -749,6 +779,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::select(['$id', 'invalidAttribute'])->toString(),
@@ -783,35 +814,709 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
'teamId' => $team['body']['$id'],
- 'region' => System::getEnv('_APP_REGION', 'default')
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ 'description' => 'My description',
+ 'logo' => 'https://google.com/logo.png',
+ 'url' => 'https://myapp.com/',
+ 'legalName' => 'Legal company',
+ 'legalCountry' => 'Slovakia',
+ 'legalState' => 'Custom state',
+ 'legalCity' => 'KoÅĄice',
+ 'legalAddress' => 'Main street 32',
+ 'legalTaxId' => 'TAXID_123456'
]);
$this->assertEquals(201, $response['headers']['status-code']);
$id = $response['body']['$id'];
+ // Increase ping 3x
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/ping',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ ], $this->getHeaders()),
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => true,
+ 'senderName' => 'Custom sender',
+ 'senderEmail' => 'email@custom.com',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'replyToEmail' => 'replyto@custom.com',
+ 'replyToName' => 'Reply sender',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Add mock numbers
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+421123456789',
+ 'otp' => '123456'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Add labels
+ $response = $this->client->call(Client::METHOD_PUT, '/project/labels', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'labels' => ['custom1', 'custom2']
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create dev keys
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 1',
+ 'expire' => '2099-05-07 09:23:30.713',
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 2',
+ 'expire' => '2099-05-07 11:23:30.713'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+420987654321',
+ 'otp' => '654321'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Setup custom values for project policies
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'duration' => 135
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 54
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 7
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 9
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create webhook
+ $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'webhookId' => 'unique()',
+ 'name' => 'Webhook Test',
+ 'events' => ['users.*.create', 'users.*.update.email'],
+ 'url' => 'https://appwrite.io',
+ 'tls' => true,
+ 'authUsername' => 'username',
+ 'authPassword' => 'password',
+ ]);
+ $this->assertEquals(201, $webhook['headers']['status-code']);
+
+ // Create API key
+ $key = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'keyId' => ID::unique(),
+ 'name' => 'Key Test',
+ 'scopes' => ['teams.read', 'teams.write'],
+ ]);
+ $this->assertEquals(201, $key['headers']['status-code']);
+
+ // Create platform
+ $platform = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'platformId' => ID::unique(),
+ 'type' => 'web',
+ 'name' => 'Web App',
+ 'hostname' => 'localhost',
+ ]);
+ $this->assertEquals(201, $platform['headers']['status-code']);
+
+ // Configure OAuth provider
+ $oauth = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'clientId' => 'github-client-id',
+ 'clientSecret' => 'github-client-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertEquals(200, $oauth['headers']['status-code']);
+
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
- $this->assertNotEmpty($response['body']);
$this->assertEquals($id, $response['body']['$id']);
$this->assertEquals('Project Test', $response['body']['name']);
+ $this->assertIsString($response['body']['$createdAt']);
+ $this->assertNotEmpty($response['body']['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$createdAt']));
+
+ $this->assertIsString($response['body']['$updatedAt']);
+ $this->assertNotEmpty($response['body']['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$updatedAt']));
+
+ // $this->assertEquals('My description', $response['body']['description']); // No longer supported
+ $this->assertEquals($team['body']['$id'], $response['body']['teamId']);
+ $this->assertEquals('active', $response['body']['status']);
+ // $this->assertEquals('https://google.com/logo.png', $response['body']['logo']); // No longer supported
+ // $this->assertEquals('https://myapp.com/', $response['body']['url']); // No longer supported
+ // $this->assertEquals('Legal company', $response['body']['legalName']); // No longer supported
+ // $this->assertEquals('Slovakia', $response['body']['legalCountry']); // No longer supported
+ // $this->assertEquals('Custom state', $response['body']['legalState']); // No longer supported
+ // $this->assertEquals('KoÅĄice', $response['body']['legalCity']); // No longer supported
+ // $this->assertEquals('Main street 32', $response['body']['legalAddress']); // No longer supported
+ // $this->assertEquals('TAXID_123456', $response['body']['legalTaxId']); // No longer supported
+ $this->assertEquals(135, $response['body']['authDuration']);
+ $this->assertEquals(54, $response['body']['authLimit']);
+ $this->assertEquals(7, $response['body']['authSessionsLimit']);
+ $this->assertEquals(9, $response['body']['authPasswordHistory']);
+ $this->assertTrue($response['body']['authPasswordDictionary']);
+ $this->assertTrue($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authDisposableEmails']);
+ $this->assertFalse($response['body']['authCanonicalEmails']);
+ $this->assertFalse($response['body']['authFreeEmails']);
+ $this->assertTrue($response['body']['authSessionAlerts']);
+ $this->assertTrue($response['body']['authMembershipsUserName']);
+ $this->assertTrue($response['body']['authMembershipsUserEmail']);
+ $this->assertTrue($response['body']['authMembershipsMfa']);
+ $this->assertTrue($response['body']['authMembershipsUserId']);
+ $this->assertTrue($response['body']['authMembershipsUserPhone']);
+ $this->assertTrue($response['body']['authInvalidateSessions']);
+ $this->assertTrue($response['body']['smtpEnabled']);
+ $this->assertSame('Custom sender', $response['body']['smtpSenderName']);
+ $this->assertSame('email@custom.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('Reply sender', $response['body']['smtpReplyToName']);
+ $this->assertSame('replyto@custom.com', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('', $response['body']['smtpSecure']);
+ $this->assertSame(3, $response['body']['pingCount']);
+
+ $this->assertIsString($response['body']['pingedAt']);
+ $this->assertNotEmpty($response['body']['pingedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['pingedAt']));
+
+ $this->assertCount(2, $response['body']['labels']);
+ $this->assertEquals('custom1', $response['body']['labels'][0]);
+ $this->assertEquals('custom2', $response['body']['labels'][1]);
+
+ $this->assertCount(2, $response['body']['devKeys']);
+ $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']);
+ $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']);
+ $this->assertEquals('2099-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']);
+ $this->assertEquals('2099-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']);
+
+ foreach ($response['body']['devKeys'] as $devKey) {
+ $this->assertIsString($devKey['$id']);
+ $this->assertNotEmpty($devKey['$id']);
+
+ $this->assertIsString($devKey['secret']);
+ $this->assertNotEmpty($devKey['secret']);
+
+ $this->assertIsString($devKey['accessedAt']);
+ $this->assertEmpty($devKey['accessedAt']);
+
+ $this->assertIsString($devKey['$createdAt']);
+ $this->assertNotEmpty($devKey['$createdAt']);
+ $this->assertNotFalse(\strtotime($devKey['$createdAt']));
+
+ $this->assertIsString($devKey['$updatedAt']);
+ $this->assertNotEmpty($devKey['$updatedAt']);
+ $this->assertNotFalse(\strtotime($devKey['$updatedAt']));
+
+ $this->assertIsArray($devKey['sdks']);
+ $this->assertCount(0, $devKey['sdks']);
+ }
+
+ $this->assertCount(2, $response['body']['authMockNumbers']);
+ $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']);
+ $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']);
+ $this->assertEquals('123456', $response['body']['authMockNumbers'][0]['otp']);
+ $this->assertEquals('654321', $response['body']['authMockNumbers'][1]['otp']);
+
+ foreach ($response['body']['authMockNumbers'] as $mockNumber) {
+ $this->assertIsString($mockNumber['$createdAt']);
+ $this->assertNotEmpty($mockNumber['$createdAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$createdAt']));
+
+ $this->assertIsString($mockNumber['$updatedAt']);
+ $this->assertNotEmpty($mockNumber['$updatedAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$updatedAt']));
+
+ $this->assertIsString($mockNumber['phone']);
+ $this->assertNotEmpty($mockNumber['phone']);
+
+ $this->assertIsString($mockNumber['otp']);
+ $this->assertNotEmpty($mockNumber['otp']);
+ }
+
+ $this->assertIsArray($response['body']['oAuthProviders']);
+ $this->assertGreaterThan(0, count($response['body']['oAuthProviders']));
+
+ $githubProvider = null;
+ foreach ($response['body']['oAuthProviders'] as $provider) {
+ $this->assertIsString($provider['key']);
+ $this->assertNotEmpty($provider['key']);
+
+ $this->assertIsString($provider['name']);
+ $this->assertIsString($provider['appId']);
+ $this->assertIsString($provider['secret']);
+ $this->assertIsBool($provider['enabled']);
+
+ if ($provider['key'] === 'github') {
+ $githubProvider = $provider;
+ }
+ }
+
+ $this->assertNotNull($githubProvider, 'GitHub provider not found');
+ $this->assertEquals('github-client-id', $githubProvider['appId']);
+ $this->assertEquals('', $githubProvider['secret']); // Write only
+ $this->assertEquals(false, $githubProvider['enabled']);
+
+ $this->assertIsArray($response['body']['platforms']);
+ $this->assertCount(1, $response['body']['platforms']);
+ $this->assertIsString($response['body']['platforms'][0]['$id']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$id']);
+ $this->assertEquals('Web App', $response['body']['platforms'][0]['name']);
+ $this->assertEquals('web', $response['body']['platforms'][0]['type']);
+ $this->assertEquals('localhost', $response['body']['platforms'][0]['hostname']);
+
+ $this->assertIsString($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$createdAt']));
+
+ $this->assertIsString($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('webhooks', $response['body']);
+ $this->assertIsArray($response['body']['webhooks']);
+ $this->assertCount(1, $response['body']['webhooks']);
+ $this->assertIsString($response['body']['webhooks'][0]['$id']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$id']);
+ $this->assertEquals('Webhook Test', $response['body']['webhooks'][0]['name']);
+ $this->assertEquals('https://appwrite.io', $response['body']['webhooks'][0]['url']);
+ $this->assertContains('users.*.create', $response['body']['webhooks'][0]['events']);
+ $this->assertContains('users.*.update.email', $response['body']['webhooks'][0]['events']);
+ $this->assertCount(2, $response['body']['webhooks'][0]['events']);
+ $this->assertTrue($response['body']['webhooks'][0]['tls']);
+ $this->assertEquals('username', $response['body']['webhooks'][0]['authUsername']);
+ $this->assertEquals('password', $response['body']['webhooks'][0]['authPassword']);
+ $this->assertTrue($response['body']['webhooks'][0]['enabled']);
+ $this->assertIsString($response['body']['webhooks'][0]['secret']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['secret']);
+ $this->assertIsString($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$createdAt']));
+ $this->assertIsString($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('keys', $response['body']);
+ $this->assertIsArray($response['body']['keys']);
+ $this->assertCount(1, $response['body']['keys']);
+ $this->assertIsString($response['body']['keys'][0]['$id']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$id']);
+ $this->assertEquals('Key Test', $response['body']['keys'][0]['name']);
+ $this->assertContains('teams.read', $response['body']['keys'][0]['scopes']);
+ $this->assertContains('teams.write', $response['body']['keys'][0]['scopes']);
+ $this->assertCount(2, $response['body']['keys'][0]['scopes']);
+ $this->assertNotEmpty($response['body']['keys'][0]['secret']);
+ $this->assertEmpty($response['body']['keys'][0]['accessedAt']);
+ $this->assertIsArray($response['body']['keys'][0]['sdks']);
+ $this->assertCount(0, $response['body']['keys'][0]['sdks']);
+ $this->assertIsString($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$createdAt']));
+ $this->assertIsString($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$updatedAt']));
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey);
+ }
+
+ // Ensure booleans can be falsy
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Toggle auth methods, services, protocols
+
+ $authMethods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
+ foreach ($authMethods as $authMethod) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/' . $authMethod,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $protocols = ['rest', 'graphql', 'websocket'];
+ foreach ($protocols as $protocol) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/protocols/' . $protocol,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $services = [
+ 'account',
+ 'avatars',
+ 'databases',
+ 'tablesdb',
+ 'locale',
+ 'health',
+ 'project',
+ 'storage',
+ 'teams',
+ 'users',
+ 'vcs',
+ 'sites',
+ 'functions',
+ 'proxy',
+ 'graphql',
+ 'migrations',
+ 'messaging',
+ ];
+
+ foreach ($services as $service) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/services/' . $service,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ 'host' => 'customhost.com',
+ 'port' => 4444,
+ 'username' => 'myuser',
+ 'password' => 'mypassword',
+ 'secure' => 'ssl',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $this->assertFalse($response['body']['authPasswordDictionary']);
+ $this->assertFalse($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authSessionAlerts']);
+ $this->assertFalse($response['body']['authMembershipsUserName']);
+ $this->assertFalse($response['body']['authMembershipsUserEmail']);
+ $this->assertFalse($response['body']['authMembershipsMfa']);
+ $this->assertFalse($response['body']['authMembershipsUserId']);
+ $this->assertFalse($response['body']['authMembershipsUserPhone']);
+ $this->assertFalse($response['body']['authInvalidateSessions']);
+ $this->assertFalse($response['body']['smtpEnabled']);
+ $this->assertSame('customhost.com', $response['body']['smtpHost']);
+ $this->assertSame(4444, $response['body']['smtpPort']);
+ $this->assertSame('myuser', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('ssl', $response['body']['smtpSecure']);
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertFalse($response['body'][$authsKey], 'Auth method should be disabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertFalse($response['body'][$serviceKey], 'Service should be disabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertFalse($response['body'][$protocolKey], 'Protocol should be disabled: ' . $protocolKey);
+ }
+
/**
* Test for FAILURE
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/empty', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
@@ -820,9 +1525,10 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/'.$projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
- $this->assertEquals(400, $response['headers']['status-code']);
+ $this->assertEquals(404, $response['headers']['status-code']);
}
public function testGetProjectUsage(): void
@@ -850,6 +1556,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -866,6 +1573,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -886,6 +1594,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => '',
@@ -910,6 +1619,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'mailer@appwrite.io',
@@ -935,6 +1645,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -954,6 +1665,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'fail@appwrite.io',
@@ -993,6 +1705,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1181,6 +1894,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Session Alert Locale Fallback Test',
@@ -1194,6 +1908,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'mailer@appwrite.io',
@@ -1262,6 +1977,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertTrue($response['body']['authSessionAlerts']);
@@ -1384,6 +2100,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1496,6 +2213,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1519,6 +2237,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Session Invalidation Test Project',
@@ -1533,6 +2252,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1550,6 +2270,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1567,6 +2288,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1591,6 +2313,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1610,6 +2333,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => $key,
'appId' => 'AppId-' . ucfirst($key),
@@ -1623,6 +2347,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1650,6 +2375,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => $key,
'enabled' => $i === 0 ? false : true // On first provider, test enabled=false
@@ -1664,6 +2390,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -1693,6 +2420,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/oauth2', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'provider' => 'unknown',
'appId' => 'AppId',
@@ -1720,6 +2448,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test',
@@ -1775,6 +2504,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
@@ -2185,6 +2915,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), []);
$this->assertEquals(400, $response['headers']['status-code']);
@@ -2194,6 +2925,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
'phone' => '+1655513432',
@@ -2207,6 +2939,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2222,6 +2955,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2237,6 +2971,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2252,6 +2987,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2267,6 +3003,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2282,6 +3019,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2309,6 +3047,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => $numbers
]);
@@ -2322,6 +3061,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => []
]);
@@ -2330,6 +3070,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/mock-numbers', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'numbers' => [
[
@@ -2653,6 +3394,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -2688,6 +3430,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2717,6 +3460,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2742,6 +3486,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -2772,6 +3517,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2794,6 +3540,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2817,6 +3564,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'projectId' => ID::unique(),
@@ -2857,6 +3605,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -2933,6 +3682,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -3009,6 +3759,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]));
@@ -3767,6 +4518,7 @@ class ProjectsConsoleClientTest extends Scope
$response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Project Test 2',
@@ -4815,6 +5567,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project',
@@ -4840,6 +5593,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(200, $project['headers']['status-code']);
@@ -4863,6 +5617,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()));
$this->assertEquals(404, $project['headers']['status-code']);
@@ -4886,6 +5641,7 @@ class ProjectsConsoleClientTest extends Scope
$project1 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project 1',
@@ -4896,6 +5652,7 @@ class ProjectsConsoleClientTest extends Scope
$project2 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Amazing Project 2',
@@ -6238,6 +6995,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Test project - Labels 1',
@@ -6282,6 +7040,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['nonvip'])->toString(),
@@ -6294,6 +7053,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6305,6 +7065,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['imagine'])->toString(),
@@ -6317,6 +7078,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['nonvip', 'imagine'])->toString(),
@@ -6330,6 +7092,7 @@ class ProjectsConsoleClientTest extends Scope
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Test project - Labels 2',
@@ -6358,6 +7121,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['imagine'])->toString(),
@@ -6372,6 +7136,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6385,6 +7150,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip'])->toString(),
@@ -6399,6 +7165,7 @@ class ProjectsConsoleClientTest extends Scope
$projects = $this->client->call(Client::METHOD_GET, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
], $this->getHeaders()), [
'queries' => [
Query::contains('labels', ['vip', 'imagine'])->toString(),
@@ -6504,6 +7271,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
]);
@@ -6600,6 +7368,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'name' => $newProjectName,
@@ -6616,6 +7385,7 @@ class ProjectsConsoleClientTest extends Scope
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-response-format' => '1.9.4',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'name' => $newProjectName,
diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php
index 32ee13b5f7..4811fc3737 100644
--- a/tests/e2e/Services/Proxy/ProxyBase.php
+++ b/tests/e2e/Services/Proxy/ProxyBase.php
@@ -171,8 +171,8 @@ trait ProxyBase
$siteId = $this->setupSite()['siteId'];
- $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId);
- $this->assertNotEmpty($ruleId);
+ $ruleId301 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 301, 'site', $siteId);
+ $this->assertNotEmpty($ruleId301);
$response = $proxyClient->call(Client::METHOD_GET, '/todos/1');
$this->assertEquals(200, $response['headers']['status-code']);
@@ -187,8 +187,8 @@ trait ProxyBase
$this->assertEquals('https://jsonplaceholder.typicode.com/todos/1', $response['headers']['location']);
$domain = \uniqid() . '-redirect-307.custom.localhost';
- $ruleId = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId);
- $this->assertNotEmpty($ruleId);
+ $ruleId307 = $this->setupRedirectRule($domain, 'https://jsonplaceholder.typicode.com/todos/1', 307, 'site', $siteId);
+ $this->assertNotEmpty($ruleId307);
$proxyClient = new Client();
$proxyClient->setEndpoint('http://appwrite.test');
@@ -209,8 +209,9 @@ trait ProxyBase
$this->assertEquals(200, $rules['headers']['status-code']);
$this->assertEquals(2, $rules['body']['total']);
+ $this->cleanupRule($ruleId301);
+ $this->cleanupRule($ruleId307);
$this->cleanupSite($siteId);
- $this->cleanupRule($ruleId);
}
public function testCreateFunctionRule(): void
diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php
index 2beae74d3e..a32b990b9e 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -2633,6 +2633,7 @@ class SitesCustomServerTest extends Scope
// Poll for execution logs to be written (async)
// Filter by requestPath to avoid picking up screenshot worker executions
+ // Wait for both the execution entry AND its logs field to be populated
$logs = null;
$timeout = 120;
$start = \time();
@@ -2642,12 +2643,13 @@ class SitesCustomServerTest extends Scope
Query::equal('requestPath', ['/logs-inline'])->toString(),
Query::limit(1)->toString(),
]);
- if (!empty($logs['body']['executions'])) {
+ if (!empty($logs['body']['executions']) && !empty($logs['body']['executions'][0]['logs'])) {
break;
}
\usleep(500000);
}
$this->assertNotEmpty($logs['body']['executions'], 'Execution logs were not available within timeout');
+ $this->assertNotNull($logs['body']['executions'][0]['logs'], 'Execution logs content was not populated within timeout');
$this->assertEquals(200, $logs['headers']['status-code']);
$this->assertStringContainsString($deploymentId, $logs['body']['executions'][0]['deploymentId']);
$this->assertStringContainsString("GET", $logs['body']['executions'][0]['requestMethod']);
@@ -2681,11 +2683,21 @@ class SitesCustomServerTest extends Scope
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Action logs printed.", $response['body']);
- $logs = $this->listLogs($siteId, [
- Query::orderDesc('$createdAt')->toString(),
- Query::equal('requestPath', ['/logs-action'])->toString(),
- Query::limit(1)->toString(),
- ]);
+ $logs = null;
+ $start = \time();
+ while (\time() - $start < $timeout) {
+ $logs = $this->listLogs($siteId, [
+ Query::orderDesc('$createdAt')->toString(),
+ Query::equal('requestPath', ['/logs-action'])->toString(),
+ Query::limit(1)->toString(),
+ ]);
+ if (!empty($logs['body']['executions']) && !empty($logs['body']['executions'][0]['logs'])) {
+ break;
+ }
+ \usleep(500000);
+ }
+ $this->assertNotEmpty($logs['body']['executions'], 'Action execution logs were not available within timeout');
+ $this->assertNotNull($logs['body']['executions'][0]['logs'], 'Action execution logs content was not populated within timeout');
$this->assertEquals(200, $logs['headers']['status-code']);
$this->assertStringContainsString($deploymentId, $logs['body']['executions'][0]['deploymentId']);
$this->assertStringContainsString("GET", $logs['body']['executions'][0]['requestMethod']);
diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php
index 29f7d70435..5e09031a9c 100644
--- a/tests/e2e/Services/Storage/StorageBase.php
+++ b/tests/e2e/Services/Storage/StorageBase.php
@@ -957,6 +957,68 @@ trait StorageBase
$this->assertNotEquals($imageBefore->getImageBlob(), $imageAfter->getImageBlob());
}
+ public function testFilePreviewCacheControlOnCacheHit(): void
+ {
+ $data = $this->setupBucketFile();
+ $bucketId = $data['bucketId'];
+ $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'),
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $file['headers']['status-code']);
+ $this->assertNotEmpty($file['body']['$id']);
+
+ $fileId = $file['body']['$id'];
+ $params = [
+ 'width' => 123,
+ 'height' => 45,
+ 'output' => 'png',
+ 'quality' => 80,
+ ];
+ $headers = array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders());
+
+ $preview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals(200, $preview['headers']['status-code']);
+ $this->assertEquals('image/png', $preview['headers']['content-type']);
+ $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']);
+ $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']);
+ $this->assertNotEmpty($preview['body']);
+
+ $cachedPreview = [];
+ $this->assertEventually(function () use (&$cachedPreview, $bucketId, $fileId, $headers, $params) {
+ $cachedPreview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']);
+ });
+
+ $this->assertEquals(200, $cachedPreview['headers']['status-code']);
+ $this->assertEquals('image/png', $cachedPreview['headers']['content-type']);
+ $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']);
+ $this->assertEquals($preview['body'], $cachedPreview['body']);
+ }
+
public function testFilePreviewZstdCompression(): void
{
$data = $this->setupZstdCompressionBucket();
diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
index 854e7110f1..23007339de 100644
--- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php
+++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
@@ -513,6 +513,59 @@ class VCSConsoleClientTest extends Scope
$this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
$this->assertEquals($repositoryBranches['body']['branches'][1]['name'], 'test');
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'search' => 'tes',
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 1);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::offset(1)->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'main']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorBefore(new \Utopia\Database\Document(['$id' => 'test']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
+
/**
* Test for FAILURE
*/
@@ -522,6 +575,16 @@ class VCSConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(404, $repositoryBranches['headers']['status-code']);
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'missing-branch']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(400, $repositoryBranches['headers']['status-code']);
}
public function testCreateFunctionUsingVCS(): void
diff --git a/tests/unit/Advisor/Validator/CTAsTest.php b/tests/unit/Advisor/Validator/CTAsTest.php
new file mode 100644
index 0000000000..5511910072
--- /dev/null
+++ b/tests/unit/Advisor/Validator/CTAsTest.php
@@ -0,0 +1,241 @@
+assertFalse($validator->isValid('not-an-array'));
+ $this->assertFalse($validator->isValid(42));
+ $this->assertFalse($validator->isValid(null));
+ }
+
+ public function testAcceptsEmptyArray(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([]));
+ }
+
+ public function testAcceptsCompleteEntry(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => [
+ 'databaseId' => 'main',
+ 'tableId' => 'orders',
+ ],
+ ]]));
+ }
+
+ public function testAcceptsEntryWithoutParams(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryMissingRequiredKeys(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([['label' => 'x']]));
+ $this->assertFalse($validator->isValid([['label' => 'x', 'service' => 'tablesDB']]));
+ $this->assertFalse($validator->isValid([['label' => 'x', 'method' => 'createIndex']]));
+ }
+
+ public function testRejectsEntryWithEmptyStrings(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => '',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithNonStringFields(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 123,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithScalarParams(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => 'not-a-map',
+ ]]));
+ }
+
+ public function testReportsArrayType(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertTrue($validator->isArray());
+ $this->assertSame($validator::TYPE_ARRAY, $validator->getType());
+ }
+
+ public function testRejectsMoreThanMaxCount(): void
+ {
+ $validator = new CTAs(maxCount: 3);
+
+ $entries = [];
+ for ($i = 0; $i < 4; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertFalse($validator->isValid($entries));
+ $this->assertStringContainsString('maximum of 3', $validator->getDescription());
+ }
+
+ public function testAcceptsExactlyMaxCount(): void
+ {
+ $validator = new CTAs(maxCount: 3);
+
+ $entries = [];
+ for ($i = 0; $i < 3; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertTrue($validator->isValid($entries));
+ }
+
+ public function testAcceptsObjectParams(): void
+ {
+ $validator = new CTAs();
+
+ $entry = [
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ 'params' => new \stdClass(),
+ ];
+
+ $this->assertTrue($validator->isValid([$entry]));
+ }
+
+ public function testRejectsEntryWithEmptyService(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => '',
+ 'method' => 'createIndex',
+ ]]));
+ }
+
+ public function testRejectsEntryWithEmptyMethod(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => '',
+ ]]));
+ }
+
+ public function testRejectsUnknownService(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'nonExistentService',
+ 'method' => 'createIndex',
+ ]]));
+ $this->assertStringContainsString('service', $validator->getDescription());
+ }
+
+ public function testRejectsUnknownMethod(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Create missing index',
+ 'service' => 'tablesDB',
+ 'method' => 'nonExistentMethod',
+ ]]));
+ $this->assertStringContainsString('method', $validator->getDescription());
+ }
+
+ public function testAcceptsCustomAllowedLists(): void
+ {
+ $validator = new CTAs(
+ allowedServices: ['custom'],
+ allowedMethods: ['doThing'],
+ );
+
+ $this->assertTrue($validator->isValid([[
+ 'label' => 'Custom action',
+ 'service' => 'custom',
+ 'method' => 'doThing',
+ ]]));
+
+ $this->assertFalse($validator->isValid([[
+ 'label' => 'Custom action',
+ 'service' => 'tablesDB',
+ 'method' => 'doThing',
+ ]]));
+ }
+
+ public function testDefaultMaxCountIsSixteen(): void
+ {
+ $validator = new CTAs();
+
+ $this->assertSame(CTAs::MAX_COUNT_DEFAULT, 16);
+
+ $entries = [];
+ for ($i = 0; $i < 16; $i++) {
+ $entries[] = [
+ 'label' => 'Label ' . $i,
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+ }
+
+ $this->assertTrue($validator->isValid($entries));
+
+ $entries[] = [
+ 'label' => 'Label 16',
+ 'service' => 'tablesDB',
+ 'method' => 'createIndex',
+ ];
+
+ $this->assertFalse($validator->isValid($entries));
+ }
+}
diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php
index f5a30a5500..74c68303f4 100644
--- a/tests/unit/Utopia/ResponseTest.php
+++ b/tests/unit/Utopia/ResponseTest.php
@@ -34,10 +34,11 @@ class ResponseTest extends TestCase
$this->assertTrue($this->response->hasFilters());
$this->assertCount(2, $this->response->getFilters());
- $output = $this->response->applyFilters([
+ $content = [
'initial' => true,
'first' => false
- ], 'test');
+ ];
+ $output = $this->response->applyFilters($content, 'test', raw: new Document($content));
$this->assertArrayHasKey('initial', $output);
$this->assertTrue($output['initial']);
diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php
new file mode 100644
index 0000000000..29973089c6
--- /dev/null
+++ b/tests/unit/Vcs/CommentTest.php
@@ -0,0 +1,154 @@
+ 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ public function testTipIsRestoredFromParsedComment(): void
+ {
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $original = $comment->generateComment();
+ $originalTip = $this->extractTip($original);
+
+ $parsed = new Comment(['consoleHostname' => 'localhost']);
+ $parsed->parseComment($original);
+ $parsed->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func2', 'name' => 'Another Function']),
+ 'function',
+ 'building',
+ 'dep2',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $regenerated = $parsed->generateComment();
+ $regeneratedTip = $this->extractTip($regenerated);
+
+ $this->assertEquals($originalTip, $regeneratedTip);
+ }
+
+ public function testBackwardCompatibilityWithOldStateFormat(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+ $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($oldState);
+
+ $new = $comment->generateComment();
+ $newTip = $this->extractTip($new);
+
+ $this->assertNotNull($newTip);
+ $this->assertNotEquals('Old tip that should be ignored', $newTip);
+ $this->assertContains($newTip, $this->getTips());
+ }
+
+ public function testParseOldStateFormatWithOnlyBuilds(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($state);
+
+ $this->assertEquals(false, $comment->isEmpty());
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+ $this->assertContains($firstTip, $this->getTips());
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ private function extractTip(string $comment): ?string
+ {
+ if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ private function getTips(): array
+ {
+ $reflection = new \ReflectionClass(Comment::class);
+ $property = $reflection->getProperty('tips');
+
+ return $property->getValue(new Comment(['consoleHostname' => 'localhost']));
+ }
+}