mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
@@ -0,0 +1,109 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
|
||||
language: en-US
|
||||
|
||||
tone_instructions: >-
|
||||
Be direct and concise. Never use em dashes or en dashes in any comment,
|
||||
summary, or suggested code. Use periods, commas, colons, or parentheses.
|
||||
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
review_status: true
|
||||
poem: false
|
||||
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
# main is the default branch and is always reviewed implicitly.
|
||||
# dev has to be listed here or feature PRs get no review at all.
|
||||
base_branches:
|
||||
- dev
|
||||
# Label the dev to main roll-up PRs to skip a duplicate review of code
|
||||
# that was already reviewed on the way into dev.
|
||||
labels:
|
||||
- '!skip-ai-review'
|
||||
ignore_title_keywords:
|
||||
- WIP
|
||||
- DO NOT MERGE
|
||||
|
||||
path_filters:
|
||||
- '!**/node_modules/**'
|
||||
- '!**/pnpm-lock.yaml'
|
||||
- '!**/.DS_Store'
|
||||
- '!api/dist/**'
|
||||
- '!api/coverage/**'
|
||||
- '!web/.next/**'
|
||||
- '!web/out/**'
|
||||
- '!web/test-results/**'
|
||||
- '!web/playwright-report/**'
|
||||
- '!web/next-env.d.ts'
|
||||
- '!android/build/**'
|
||||
- '!android/app/build/**'
|
||||
- '!android/.gradle/**'
|
||||
- '!android/local.properties'
|
||||
- '!**/google-services.json'
|
||||
- '!**/*.apk'
|
||||
|
||||
path_instructions:
|
||||
- path: '**'
|
||||
instructions: |
|
||||
Never use em dashes or en dashes anywhere, including code comments,
|
||||
JSDoc, alt text, metadata, and commit messages. Use a period, comma,
|
||||
colon, parentheses, or a plain hyphen instead.
|
||||
This project uses pnpm only. Flag npm or yarn commands in scripts,
|
||||
docs, CI, and Dockerfiles.
|
||||
Keep functions small and single-purpose.
|
||||
|
||||
- path: 'api/src/**/*.ts'
|
||||
instructions: |
|
||||
NestJS and Mongoose service. Focus on authentication, authorization,
|
||||
tenant scoping, and input validation. Flag any query that can read or
|
||||
write another user's data without an owner or user filter. Flag secrets,
|
||||
API keys, or tokens that get logged or returned in responses.
|
||||
Note that tsconfig has strictNullChecks and noImplicitAny disabled, so
|
||||
null and undefined handling is not compiler-enforced here.
|
||||
|
||||
- path: 'web/**/*.{ts,tsx}'
|
||||
instructions: |
|
||||
Next.js 16 App Router with React 19. Flag server-only data or secrets
|
||||
leaking into client components, missing loading and error states, and
|
||||
useEffect dependency mistakes. eslint downgrades several react-hooks
|
||||
rules to warn, so call those out in review instead.
|
||||
|
||||
- path: 'web/components/ui/**'
|
||||
instructions: |
|
||||
Vendored shadcn/ui primitives. Only comment on deliberate local
|
||||
modifications, not on generator-produced style.
|
||||
|
||||
- path: 'android/**/*.{kt,java}'
|
||||
instructions: |
|
||||
Android SMS gateway. Focus on permission handling, leaked Context or
|
||||
Activity references, work done on the main thread, and SMS send or
|
||||
receive edge cases. There is no ktlint or detekt in CI, so style issues
|
||||
are worth flagging here.
|
||||
|
||||
- path: '.github/workflows/**'
|
||||
instructions: |
|
||||
Flag unpinned action versions, secrets echoed into logs or files, and
|
||||
steps that would run untrusted code from fork pull requests.
|
||||
|
||||
tools:
|
||||
# Waits for ALL GitHub Checks, then comments on failures.
|
||||
github-checks:
|
||||
enabled: true
|
||||
|
||||
chat:
|
||||
auto_reply: true
|
||||
# Public repo, so outside contributors can spend the chat quota.
|
||||
# Set to false if that becomes a problem.
|
||||
allow_non_org_members: true
|
||||
|
||||
knowledge_base:
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
pull_requests:
|
||||
scope: auto
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'android/**'
|
||||
- '.github/workflows/android.yaml'
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'android/**'
|
||||
- '.github/workflows/android.yaml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run workflow on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Android
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# google-services.json is written from repository secrets, and GitHub
|
||||
# withholds secrets from fork pull requests, so skip on forks rather than
|
||||
# fail every outside contribution.
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [dev, prod]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
gradle-version: '7.2'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Create debug keystore
|
||||
run: |
|
||||
mkdir -p ~/.android
|
||||
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Create google-services.json
|
||||
env:
|
||||
GOOGLE_SERVICES_JSON: ${{ matrix.variant == 'dev' && secrets.GOOGLE_SERVICES_JSON_DEV || secrets.GOOGLE_SERVICES_JSON_PROD }}
|
||||
run: |
|
||||
mkdir -p android/app/src/${{ matrix.variant }}
|
||||
printf '%s' "$GOOGLE_SERVICES_JSON" > android/app/src/${{ matrix.variant }}/google-services.json
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assemble${{ matrix.variant }}Debug
|
||||
|
||||
# - name: Run Android tests
|
||||
# run: |
|
||||
# cd android
|
||||
# ./gradlew test${{ matrix.variant }}DebugUnitTest
|
||||
|
||||
- name: Sanitize ref name for artifact
|
||||
shell: bash
|
||||
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
|
||||
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,74 @@
|
||||
name: API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api.yaml'
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api.yaml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run workflow on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Superseded runs on the same branch or PR are cancelled.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test API
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
|
||||
# has no package manager to query.
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: api/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Unit test
|
||||
run: pnpm test
|
||||
@@ -1,179 +0,0 @@
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'web/**'
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-and-test.yaml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run workflow on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
android_variant:
|
||||
description: 'Android build variant'
|
||||
required: true
|
||||
default: 'dev'
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
build-and-test-web-and-api:
|
||||
name: Build and Test web and api
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Build and test API
|
||||
run: |
|
||||
cd api
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm test
|
||||
|
||||
# Web previously ran `build` only, so its unit and e2e suites never
|
||||
# gated a merge. Lint, typecheck and unit tests are fast and
|
||||
# deterministic, so they block.
|
||||
- name: Install web dependencies
|
||||
run: |
|
||||
cd web
|
||||
pnpm install
|
||||
|
||||
- name: Lint web
|
||||
run: |
|
||||
cd web
|
||||
pnpm lint
|
||||
|
||||
- name: Typecheck web
|
||||
run: |
|
||||
cd web
|
||||
pnpm typecheck
|
||||
|
||||
- name: Unit test web
|
||||
run: |
|
||||
cd web
|
||||
pnpm test
|
||||
|
||||
- name: Build web
|
||||
run: |
|
||||
cd web
|
||||
pnpm run build
|
||||
|
||||
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call),
|
||||
# but it drives a real browser, so it is reported without blocking until
|
||||
# it has proven stable across a few merges.
|
||||
#
|
||||
# TODO: remove continue-on-error once that is established. It is here to
|
||||
# avoid wedging the merge queue on a browser flake, not because e2e
|
||||
# failures are acceptable.
|
||||
- name: Install Playwright browser
|
||||
run: |
|
||||
cd web
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: E2e test web (non-blocking for now)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd web
|
||||
pnpm test:e2e
|
||||
|
||||
build-and-test-android:
|
||||
name: Build and Test Android
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [dev, prod]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
gradle-version: '7.2'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Create debug keystore
|
||||
run: |
|
||||
mkdir -p ~/.android
|
||||
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Create google-services.json for dev
|
||||
if: matrix.variant == 'dev'
|
||||
run: |
|
||||
mkdir -p android/app/src/dev
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_DEV }}' > android/app/src/dev/google-services.json
|
||||
|
||||
- name: Create google-services.json for prod
|
||||
if: matrix.variant == 'prod'
|
||||
run: |
|
||||
mkdir -p android/app/src/prod
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_PROD }}' > android/app/src/prod/google-services.json
|
||||
|
||||
- name: Build Android app
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assemble${{ matrix.variant }}Debug
|
||||
|
||||
# - name: Run Android tests
|
||||
# run: |
|
||||
# cd android
|
||||
# ./gradlew test${{ matrix.variant }}DebugUnitTest
|
||||
|
||||
- name: Sanitize ref name for artifact
|
||||
shell: bash
|
||||
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
|
||||
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Web
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.github/workflows/web.yaml'
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.github/workflows/web.yaml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run workflow on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Lint, typecheck, unit test, build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
# pnpm has to be on PATH before setup-node, or its pnpm cache resolution
|
||||
# has no package manager to query.
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Unit test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call), but it
|
||||
# drives a real browser and playwright's webServer does its own production
|
||||
# build, so it ran about three times longer than everything else combined.
|
||||
# It sits in its own job so it never delays the blocking signal above.
|
||||
#
|
||||
# TODO: remove continue-on-error once e2e has proven stable across a few
|
||||
# merges. It is here to avoid wedging on a browser flake, not because e2e
|
||||
# failures are acceptable.
|
||||
e2e:
|
||||
name: e2e
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# No build step: playwright.config.ts runs `pnpm build && pnpm start` as
|
||||
# its webServer, and the checks job above already gates the build.
|
||||
- name: Run e2e
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ github.run_id }}
|
||||
path: web/playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger'
|
||||
import {
|
||||
LoginInputDTO,
|
||||
RegisterInputDTO,
|
||||
@@ -58,6 +64,7 @@ export class AuthController {
|
||||
|
||||
@ApiOperation({ summary: 'Get current logged in user' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('/who-am-i')
|
||||
async whoAmI(@Request() req) {
|
||||
@@ -67,6 +74,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Update Profile' })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Patch('/update-profile')
|
||||
async updateProfile(
|
||||
@@ -79,6 +87,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Change Password' })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('/change-password')
|
||||
async changePassword(
|
||||
@@ -91,6 +100,7 @@ export class AuthController {
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiOperation({ summary: 'Generate Api Key' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@Post('/api-keys')
|
||||
async generateApiKey(@Request() req) {
|
||||
const { apiKey, message } = await this.authService.generateApiKey(req.user)
|
||||
@@ -107,6 +117,7 @@ export class AuthController {
|
||||
'Filter keys: active (default), revoked only, or all (legacy full list)',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@Get('/api-keys')
|
||||
async getApiKey(
|
||||
@Request() req,
|
||||
@@ -119,6 +130,7 @@ export class AuthController {
|
||||
@UseGuards(AuthGuard, CanModifyApiKey)
|
||||
@ApiOperation({ summary: 'Delete Api Key' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Delete('/api-keys/:id')
|
||||
async deleteApiKey(@Param() params) {
|
||||
@@ -129,6 +141,7 @@ export class AuthController {
|
||||
@UseGuards(AuthGuard, CanModifyApiKey)
|
||||
@ApiOperation({ summary: 'Revoke Api Key' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/api-keys/:id/revoke')
|
||||
async revokeApiKey(@Param() params) {
|
||||
@@ -139,6 +152,7 @@ export class AuthController {
|
||||
@UseGuards(AuthGuard, CanModifyApiKey)
|
||||
@ApiOperation({ summary: 'Rename Api Key' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Patch('/api-keys/:id/rename')
|
||||
async renameApiKey(@Param() params, @Body() input: { name: string }) {
|
||||
@@ -148,6 +162,7 @@ export class AuthController {
|
||||
|
||||
@ApiOperation({ summary: 'Update dashboard onboarding progress' })
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Patch('/onboarding')
|
||||
async updateOnboarding(
|
||||
@@ -176,6 +191,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Send Email Verification Code' })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('/send-email-verification-email')
|
||||
async sendEmailVerificationEmail(@Request() req) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common'
|
||||
import { BillingService } from './billing.service'
|
||||
import { AuthGuard } from 'src/auth/guards/auth.guard'
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'
|
||||
import { ApiTags, ApiBearerAuth, ApiSecurity } from '@nestjs/swagger'
|
||||
import {
|
||||
ChangePlanInputDTO,
|
||||
ChangePlanResponseDTO,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { BillingNotificationsService } from './billing-notifications.service'
|
||||
|
||||
@ApiTags('billing')
|
||||
@ApiBearerAuth()
|
||||
@Controller('billing')
|
||||
export class BillingController {
|
||||
constructor(
|
||||
@@ -27,18 +26,24 @@ export class BillingController {
|
||||
|
||||
@Get('current-subscription')
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
async getCurrentSubscription(@Request() req: any) {
|
||||
return this.billingService.getCurrentSubscription(req.user)
|
||||
}
|
||||
|
||||
@Get('notifications')
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
async listNotifications(@Request() req: any) {
|
||||
return this.billingNotifications.listForUser(req.user._id)
|
||||
}
|
||||
|
||||
@Post('checkout')
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
async getCheckoutUrl(
|
||||
@Body() payload: CheckoutInputDTO,
|
||||
@Request() req: any,
|
||||
@@ -52,6 +57,8 @@ export class BillingController {
|
||||
|
||||
@Post('change-plan')
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
async changePlan(
|
||||
@Body() payload: ChangePlanInputDTO,
|
||||
@Request() req: any,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger'
|
||||
import { AuthGuard } from '../auth/guards/auth.guard'
|
||||
@@ -34,6 +35,7 @@ import { CanModifyDevice } from './guards/can-modify-device.guard'
|
||||
|
||||
@ApiTags('gateway')
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@Controller('gateway')
|
||||
export class GatewayController {
|
||||
constructor(private readonly gatewayService: GatewayService) {}
|
||||
|
||||
+8
-5
@@ -41,11 +41,14 @@ async function bootstrap() {
|
||||
.setDescription('TextBee - Android SMS Gateway API Docs')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addApiKey({
|
||||
type: 'apiKey',
|
||||
name: 'x-api-key',
|
||||
in: 'header',
|
||||
})
|
||||
.addApiKey(
|
||||
{
|
||||
type: 'apiKey',
|
||||
name: 'x-api-key',
|
||||
in: 'header',
|
||||
},
|
||||
'x-api-key',
|
||||
)
|
||||
.build()
|
||||
const document = SwaggerModule.createDocument(app, config)
|
||||
SwaggerModule.setup('', app, document, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'
|
||||
import { ApiBearerAuth, ApiSecurity } from '@nestjs/swagger'
|
||||
import {
|
||||
CreateSupportMessageDto,
|
||||
SupportCategory,
|
||||
@@ -16,6 +17,8 @@ export class SupportController {
|
||||
private readonly turnstileService: TurnstileService,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('customer-support')
|
||||
async createSupportMessage(
|
||||
@@ -37,6 +40,7 @@ export class SupportController {
|
||||
return this.supportService.createSupportMessage(createSupportMessageDto)
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('request-account-deletion')
|
||||
async requestAccountDeletion(
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common'
|
||||
import { WebhookService } from './webhook.service'
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
|
||||
import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'
|
||||
import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto'
|
||||
import { AuthGuard } from 'src/auth/guards/auth.guard'
|
||||
|
||||
@ApiTags('webhooks')
|
||||
@ApiBearerAuth()
|
||||
@ApiSecurity('x-api-key')
|
||||
@Controller('webhooks')
|
||||
export class WebhookController {
|
||||
constructor(private readonly webhookService: WebhookService) {}
|
||||
|
||||
+32
-11
@@ -2,6 +2,20 @@ import { expect, test } from '@playwright/test'
|
||||
import { authenticate } from './session'
|
||||
import { mockApi } from './mock-api'
|
||||
import { mockDevices } from '../test/fixtures'
|
||||
import { buildEndpoints } from '../app/(app)/dashboard/messaging/(components)/api-guide/snippets'
|
||||
|
||||
// The page streams behind a loading.tsx boundary, so its HTML can paint before
|
||||
// React hydrates, and a tab or copy click landing in that gap is silently
|
||||
// dropped. Prism tokens are not the signal to wait on: react-syntax-highlighter
|
||||
// renders its spans server-side too, so they are in the SSR payload already.
|
||||
// The snippets interpolate a device id from the client-side devices fetch, so
|
||||
// the real id appearing is what proves hydration effects have run.
|
||||
async function gotoGuide(page: import('@playwright/test').Page) {
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
await expect(
|
||||
page.getByText(mockDevices[0]._id, { exact: false }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('api guide (mocked API, no real backend)', () => {
|
||||
test('shows content immediately, not a collapsed accordion', async ({
|
||||
@@ -42,7 +56,7 @@ test.describe('api guide (mocked API, no real backend)', () => {
|
||||
test('switching language swaps every sample', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
await gotoGuide(page)
|
||||
|
||||
// cURL is the default.
|
||||
await expect(page.getByText('curl -X POST').first()).toBeVisible()
|
||||
@@ -86,22 +100,29 @@ test.describe('api guide (mocked API, no real backend)', () => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('/dashboard/messaging/api-guide')
|
||||
await gotoGuide(page)
|
||||
// navigator.clipboard.writeText rejects when the document is not focused,
|
||||
// which is the state a backgrounded page sits in while workers run in
|
||||
// parallel. Granting permissions alone does not cover it.
|
||||
await page.bringToFront()
|
||||
|
||||
await page.getByRole('button', { name: 'Copy code' }).first().click()
|
||||
// The page's first Copy code button belongs to the Base URL chip, so the
|
||||
// click has to be scoped to the send-sms sample it is asserting on.
|
||||
await page
|
||||
.locator('#send-sms')
|
||||
.getByRole('button', { name: 'Copy code' })
|
||||
.click()
|
||||
|
||||
// The button relabels itself only after the async clipboard write resolves,
|
||||
// so this is the signal that there is something to read back.
|
||||
await expect(page.getByRole('button', { name: 'Copied' }).first()).toBeVisible()
|
||||
|
||||
const clipboard = await page.evaluate(() =>
|
||||
navigator.clipboard.readText()
|
||||
)
|
||||
expect(clipboard).toContain('api.textbee.dev')
|
||||
// Assert on the clipboard, not on the button's "Copied" label. That label
|
||||
// clears itself two seconds after the click (code-block.tsx setTimeout), so
|
||||
// waiting for it races a window narrow enough to miss on a loaded runner.
|
||||
// The clipboard content is the behaviour under test and it does not expire.
|
||||
const expectedCurl = buildEndpoints(mockDevices[0]._id).find(
|
||||
(endpoint) => endpoint.id === 'send-sms'
|
||||
)!.samples.curl
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(expectedCurl)
|
||||
})
|
||||
|
||||
test('does not scroll sideways at 375px', async ({ page, context }) => {
|
||||
|
||||
@@ -2,6 +2,17 @@ import { expect, test } from '@playwright/test'
|
||||
import { authenticate } from './session'
|
||||
import { mockApi } from './mock-api'
|
||||
|
||||
// The dashboard sections stream behind a loading.tsx boundary, so their HTML
|
||||
// can paint before React hydrates. A click landing in that gap hits a button
|
||||
// whose handler is not attached yet, the dialog never opens, and the test
|
||||
// waits out its timeout on a heading that was never going to render. The
|
||||
// total-sent figure comes from the client-side gateway stats fetch, so its
|
||||
// presence proves hydration effects have run.
|
||||
async function gotoDashboard(page: import('@playwright/test').Page) {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByText('12,840')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('dashboard (mocked API, no real backend)', () => {
|
||||
test('redirects unauthenticated users to login', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
@@ -60,7 +71,7 @@ test.describe('dashboard (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard')
|
||||
await gotoDashboard(page)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Add device' })
|
||||
@@ -91,7 +102,7 @@ test.describe('dashboard (mocked API, no real backend)', () => {
|
||||
}) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard')
|
||||
await gotoDashboard(page)
|
||||
|
||||
// That button asks for a key, so device instructions would be noise.
|
||||
await page.getByRole('button', { name: 'New API key' }).click()
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { TestProviders } from '@/test/render'
|
||||
import { mockDevices, mockSubscription, mockUser } from '@/test/fixtures'
|
||||
import { useCurrentUser, useDevices, useSubscription } from './hooks'
|
||||
import { API_BASE_URL, mockDevices, mockSubscription, mockUser } from '@/test/fixtures'
|
||||
import { server } from '@/test/msw/server'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import {
|
||||
useCurrentUser,
|
||||
useDevices,
|
||||
useSubscription,
|
||||
useWebhookNotifications,
|
||||
} from './hooks'
|
||||
|
||||
// Verifies the typed hooks talk to the mocked API and unwrap the various
|
||||
// response envelopes correctly. No real backend is contacted (MSW).
|
||||
@@ -27,4 +35,42 @@ describe('data hooks', () => {
|
||||
expect(result.current.data).toHaveLength(mockDevices.length)
|
||||
expect(result.current.data?.[0]._id).toBe(mockDevices[0]._id)
|
||||
})
|
||||
|
||||
// Regression for #256: start/end were query params but not query-key
|
||||
// members, so changing the date filter reused a cached response.
|
||||
it('useWebhookNotifications refetches when the date range changes', async () => {
|
||||
const startsSeen: string[] = []
|
||||
server.use(
|
||||
http.get(
|
||||
`${API_BASE_URL}${ApiEndpoints.gateway.getWebhookNotifications().split('?')[0]}`,
|
||||
({ request }) => {
|
||||
const start = new URL(request.url).searchParams.get('start') ?? ''
|
||||
startsSeen.push(start)
|
||||
return HttpResponse.json({
|
||||
data: { data: [], meta: { totalPages: 1, total: 0 } },
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ start }) => useWebhookNotifications({ start, page: 1, limit: 10 }),
|
||||
{
|
||||
wrapper,
|
||||
initialProps: { start: '2026-08-01T00:00:00.000Z' },
|
||||
}
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(startsSeen).toEqual(['2026-08-01T00:00:00.000Z'])
|
||||
|
||||
rerender({ start: '2026-07-01T00:00:00.000Z' })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(startsSeen).toEqual([
|
||||
'2026-08-01T00:00:00.000Z',
|
||||
'2026-07-01T00:00:00.000Z',
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -343,15 +343,16 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) {
|
||||
limit = 10,
|
||||
} = filters
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'webhook-notification',
|
||||
queryKey: queryKeys.webhookNotifications({
|
||||
eventType,
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
deviceId,
|
||||
webhookSubscriptionId,
|
||||
status,
|
||||
],
|
||||
start,
|
||||
end,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
queryFn: () =>
|
||||
httpBrowserClient
|
||||
.get(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { queryKeys } from './query-keys'
|
||||
|
||||
describe('queryKeys.webhookNotifications', () => {
|
||||
it('includes start and end so date filter changes produce a new cache entry', () => {
|
||||
const base = {
|
||||
eventType: '',
|
||||
status: '',
|
||||
deviceId: '',
|
||||
webhookSubscriptionId: '',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
}
|
||||
|
||||
const withoutDates = queryKeys.webhookNotifications(base)
|
||||
const withStart = queryKeys.webhookNotifications({
|
||||
...base,
|
||||
start: '2026-08-01T00:00:00.000Z',
|
||||
})
|
||||
const withRange = queryKeys.webhookNotifications({
|
||||
...base,
|
||||
start: '2026-08-01T00:00:00.000Z',
|
||||
end: '2026-08-02T00:00:00.000Z',
|
||||
})
|
||||
const differentStart = queryKeys.webhookNotifications({
|
||||
...base,
|
||||
start: '2026-07-01T00:00:00.000Z',
|
||||
end: '2026-08-02T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(withoutDates).not.toEqual(withStart)
|
||||
expect(withStart).not.toEqual(withRange)
|
||||
expect(withRange).not.toEqual(differentStart)
|
||||
|
||||
// Guard against dropping date params from the key shape again.
|
||||
expect(withRange).toEqual([
|
||||
'webhook-notification',
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'2026-08-01T00:00:00.000Z',
|
||||
'2026-08-02T00:00:00.000Z',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -22,4 +22,27 @@ export const queryKeys = {
|
||||
filters
|
||||
? (['messages', deviceId, filters] as const)
|
||||
: (['messages', deviceId] as const),
|
||||
// start/end must be part of the key: they are sent on the request URL, and
|
||||
// react-query only refetches when the key changes (see issue #256).
|
||||
webhookNotifications: (filters: {
|
||||
eventType?: string
|
||||
status?: string
|
||||
deviceId?: string
|
||||
webhookSubscriptionId?: string
|
||||
start?: string
|
||||
end?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}) =>
|
||||
[
|
||||
'webhook-notification',
|
||||
filters.eventType ?? '',
|
||||
filters.page ?? 1,
|
||||
filters.limit ?? 10,
|
||||
filters.deviceId ?? '',
|
||||
filters.webhookSubscriptionId ?? '',
|
||||
filters.status ?? '',
|
||||
filters.start ?? '',
|
||||
filters.end ?? '',
|
||||
] as const,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user