commit 3015f481183809785eb07dcfa6eca618bb4962e3 Author: Rephl3x Date: Wed Dec 17 12:32:50 2025 +1300 Initial Upload diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..576bd2b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Dependencies - will be installed fresh in container +node_modules +.pnpm-store + +# Turbo cache - not needed in Docker build +.turbo + +# Git +.git +.gitignore + +# IDE and editor files +.idea +.vscode +*.swp +*.swo +.DS_Store + +# Testing artifacts +coverage + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Environment files - should be provided at runtime +.env +.env.local +.env.*.local + +# Docker files (avoid recursion) +docker-compose*.yml +.dockerignore + +# Documentation (not needed in image) +docs/ + +# CI/CD +.github/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..389c0eb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..acec726 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Database +DATABASE_URL=postgres://tracearr:tracearr@localhost:5432/tracearr + +# Redis +REDIS_URL=redis://localhost:6379 + +# Server +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=development +LOG_LEVEL=debug + +# Auth - CHANGE THESE IN PRODUCTION! +JWT_SECRET=your-secret-key-change-in-production +COOKIE_SECRET=your-cookie-secret-change-in-production + +# CORS (frontend URL) +CORS_ORIGIN=http://localhost:5173 + +# Mobile beta testing mode - enables reusable tokens, no expiry, unlimited devices +# MOBILE_BETA_MODE=true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ad45f0f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms + +github: connorgallopo +patreon: Gallapagos +thanks_dev: connorgallopo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a97553 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,266 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + test: + name: Test (${{ matrix.group }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + strategy: + fail-fast: false + matrix: + group: [unit, services, routes, security] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dependencies (shared, test-utils) + run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils + + - name: Run ${{ matrix.group }} tests + run: pnpm --filter @tracearr/server test:${{ matrix.group }} + + # Integration tests require real database - run separately from unit tests + test-integration: + name: Test (integration) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TEST_DATABASE_URL: postgresql://test:test@localhost:5433/tracearr_test + TEST_REDIS_URL: redis://localhost:6380 + + services: + timescale: + image: timescale/timescaledb:latest-pg15 + ports: + - 5433:5432 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: tracearr_test + options: >- + --health-cmd "pg_isready -U test -d tracearr_test" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6380:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dependencies (shared, test-utils) + run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils + + - name: Run integration tests + run: pnpm --filter @tracearr/server test:integration + + # Separate job for coverage reporting (runs all tests together for accurate coverage) + # Runs in parallel with test matrix - if tests pass there, coverage will too + test-coverage: + name: Test Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dependencies (shared, test-utils) + run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils + + - name: Run tests with coverage + run: pnpm test:coverage + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: apps/server/coverage/ + retention-days: 7 + if-no-files-found: warn + + - name: Report coverage to PR + if: github.event_name == 'pull_request' + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: apps/server + vite-config-path: vitest.config.ts + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [lint-and-typecheck, test, test-integration, test-coverage] + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml new file mode 100644 index 0000000..ea3e847 --- /dev/null +++ b/.github/workflows/mobile-build.yml @@ -0,0 +1,454 @@ +name: Mobile Build (Local) + +on: + workflow_dispatch: + inputs: + tag: + description: 'Git tag to build (e.g., v1.2.3-beta.1)' + required: true + type: string + platform: + description: 'Platform to build' + required: true + type: choice + options: + - android + - ios + - both + default: 'both' + distribution: + description: 'Distribution method' + required: true + type: choice + options: + - internal + - store + default: 'internal' + profile: + description: 'Build profile' + required: true + type: choice + options: + - preview + - production + default: 'preview' + +# Prevent concurrent builds that could cause version conflicts +concurrency: + group: mobile-build + cancel-in-progress: false + +env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + +jobs: + # Atomically prepare and reserve version numbers before builds start + prepare-versions: + name: Prepare Versions + runs-on: ubuntu-latest + timeout-minutes: 5 + defaults: + run: + working-directory: apps/mobile + outputs: + marketing-version: ${{ steps.versions.outputs.marketing-version }} + android-version-code: ${{ steps.versions.outputs.android-version-code }} + ios-build-number: ${{ steps.versions.outputs.ios-build-number }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Reserve version numbers + id: versions + env: + EAS_PROJECT_ID: "2e0b9595-ac62-493f-9a10-4f8758bb4b2d" + APP_IDENTIFIER: "com.tracearr.app" + run: | + # Extract marketing version from git tag + VERSION="${{ inputs.tag }}" + VERSION="${VERSION#v}" + VERSION="${VERSION%%-*}" + echo "Marketing version: $VERSION" + echo "marketing-version=$VERSION" >> $GITHUB_OUTPUT + + # Get current versions from EAS remote + echo "Getting current versions from EAS remote..." + REMOTE_VERSIONS=$(eas build:version:get --platform all --json --non-interactive 2>/dev/null || echo '{}') + echo "Remote versions: $REMOTE_VERSIONS" + + # Parse current versions (default to 0 if not set) + CURRENT_ANDROID_VC=$(echo "$REMOTE_VERSIONS" | jq -r '.versionCode // "0"') + CURRENT_IOS_BN=$(echo "$REMOTE_VERSIONS" | jq -r '.buildNumber // "0"') + + # Increment versions + NEXT_ANDROID_VC=$((CURRENT_ANDROID_VC + 1)) + NEXT_IOS_BN=$((CURRENT_IOS_BN + 1)) + + echo "Android versionCode: $CURRENT_ANDROID_VC -> $NEXT_ANDROID_VC" + echo "iOS buildNumber: $CURRENT_IOS_BN -> $NEXT_IOS_BN" + + # Reserve versions on EAS remote via GraphQL API + echo "Reserving Android version $NEXT_ANDROID_VC on EAS..." + ANDROID_RESULT=$(curl -sf -X POST https://api.expo.dev/graphql \ + -H "Authorization: Bearer $EXPO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"query\":\"mutation { appVersion { createAppVersion(appVersionInput: { appId: \\\"$EAS_PROJECT_ID\\\", platform: ANDROID, applicationIdentifier: \\\"$APP_IDENTIFIER\\\", storeVersion: \\\"$VERSION\\\", buildVersion: \\\"$NEXT_ANDROID_VC\\\" }) { id } } }\"}") + echo "Android result: $ANDROID_RESULT" + + echo "Reserving iOS version $NEXT_IOS_BN on EAS..." + IOS_RESULT=$(curl -sf -X POST https://api.expo.dev/graphql \ + -H "Authorization: Bearer $EXPO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"query\":\"mutation { appVersion { createAppVersion(appVersionInput: { appId: \\\"$EAS_PROJECT_ID\\\", platform: IOS, applicationIdentifier: \\\"$APP_IDENTIFIER\\\", storeVersion: \\\"$VERSION\\\", buildVersion: \\\"$NEXT_IOS_BN\\\" }) { id } } }\"}") + echo "iOS result: $IOS_RESULT" + + # Output for build jobs + echo "android-version-code=$NEXT_ANDROID_VC" >> $GITHUB_OUTPUT + echo "ios-build-number=$NEXT_IOS_BN" >> $GITHUB_OUTPUT + + echo "Versions reserved successfully" + + build-android: + name: Build Android + needs: [prepare-versions] + if: inputs.platform == 'android' || inputs.platform == 'both' + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: apps/mobile + outputs: + artifact-path: ${{ steps.build.outputs.artifact-path }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Build shared package + run: pnpm --filter @tracearr/shared build + working-directory: . + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Set versions from prepare job + run: | + VERSION="${{ needs.prepare-versions.outputs.marketing-version }}" + VERSION_CODE="${{ needs.prepare-versions.outputs.android-version-code }}" + + echo "Setting version: $VERSION, versionCode: $VERSION_CODE" + + jq --arg v "$VERSION" \ + --argjson vc "$VERSION_CODE" \ + '.expo.version = $v | .expo.android.versionCode = $vc' \ + app.json > tmp.json && mv tmp.json app.json + + - name: Build Android locally + id: build + env: + GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} + run: | + BUILD_TYPE="${{ inputs.distribution == 'internal' && 'apk' || 'app-bundle' }}" + OUTPUT_EXT="${{ inputs.distribution == 'internal' && 'apk' || 'aab' }}" + OUTPUT_FILE="tracearr-${{ inputs.tag }}-android.${OUTPUT_EXT}" + + echo "Building $BUILD_TYPE for ${{ inputs.profile }} profile..." + + eas build \ + --platform android \ + --profile ${{ inputs.profile }} \ + --local \ + --output "./${OUTPUT_FILE}" \ + --non-interactive + + echo "artifact-path=apps/mobile/${OUTPUT_FILE}" >> $GITHUB_OUTPUT + echo "Build complete: ${OUTPUT_FILE}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: android-build + path: ${{ steps.build.outputs.artifact-path }} + retention-days: 30 + + build-ios: + name: Build iOS + needs: [prepare-versions] + if: inputs.platform == 'ios' || inputs.platform == 'both' + runs-on: macos-14 + timeout-minutes: 90 + defaults: + run: + working-directory: apps/mobile + outputs: + artifact-path: ${{ steps.build.outputs.artifact-path }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Build shared package + run: pnpm --filter @tracearr/shared build + working-directory: . + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Set versions from prepare job + run: | + VERSION="${{ needs.prepare-versions.outputs.marketing-version }}" + BUILD_NUMBER="${{ needs.prepare-versions.outputs.ios-build-number }}" + + echo "Setting version: $VERSION, buildNumber: $BUILD_NUMBER" + + jq --arg v "$VERSION" \ + --arg bn "$BUILD_NUMBER" \ + '.expo.version = $v | .expo.ios.buildNumber = $bn' \ + app.json > tmp.json && mv tmp.json app.json + + - name: Build iOS locally + id: build + run: | + OUTPUT_FILE="tracearr-${{ inputs.tag }}-ios.ipa" + + echo "Building iOS for ${{ inputs.profile }} profile..." + + eas build \ + --platform ios \ + --profile ${{ inputs.profile }} \ + --local \ + --output "./${OUTPUT_FILE}" \ + --non-interactive + + echo "artifact-path=apps/mobile/${OUTPUT_FILE}" >> $GITHUB_OUTPUT + echo "Build complete: ${OUTPUT_FILE}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ios-build + path: ${{ steps.build.outputs.artifact-path }} + retention-days: 30 + + upload-and-submit: + name: Upload and Submit + needs: [prepare-versions, build-android, build-ios] + if: always() && (needs.build-android.result == 'success' || needs.build-ios.result == 'success') + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Download Android artifact + if: needs.build-android.result == 'success' + uses: actions/download-artifact@v4 + with: + name: android-build + path: apps/mobile/ + + - name: Download iOS artifact + if: needs.build-ios.result == 'success' + uses: actions/download-artifact@v4 + with: + name: ios-build + path: apps/mobile/ + + - name: Upload Android to EAS + if: needs.build-android.result == 'success' + continue-on-error: true + run: | + APK_FILE=$(ls *.apk 2>/dev/null || ls *.aab 2>/dev/null) + echo "Uploading $APK_FILE to EAS..." + eas upload --platform android --build-path "./${APK_FILE}" --non-interactive + + - name: Upload iOS to EAS + if: needs.build-ios.result == 'success' + continue-on-error: true + run: | + IPA_FILE=$(ls *.ipa) + echo "Uploading $IPA_FILE to EAS..." + eas upload --platform ios --build-path "./${IPA_FILE}" --non-interactive + + - name: Decode Google Service Account + if: inputs.distribution == 'store' && needs.build-android.result == 'success' + run: | + mkdir -p credentials + echo "${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}" | base64 -d > credentials/google-service-account.json + + - name: Submit Android to Play Store + if: inputs.distribution == 'store' && needs.build-android.result == 'success' + run: | + AAB_FILE=$(ls *.aab) + echo "Submitting $AAB_FILE to Play Store..." + eas submit --platform android --path "./${AAB_FILE}" --profile ${{ inputs.profile }} --non-interactive + + - name: Submit iOS to App Store + if: inputs.distribution == 'store' && needs.build-ios.result == 'success' + env: + EXPO_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.EXPO_APPLE_APP_SPECIFIC_PASSWORD }} + run: | + IPA_FILE=$(ls *.ipa) + echo "Submitting $IPA_FILE to App Store..." + eas submit --platform ios --path "./${IPA_FILE}" --profile ${{ inputs.profile }} --non-interactive + + summary: + name: Build Summary + needs: [prepare-versions, build-android, build-ios, upload-and-submit] + if: always() + runs-on: ubuntu-latest + + steps: + - name: Generate summary + run: | + { + echo "## Mobile Build Summary" + echo "" + echo "**Tag**: ${{ inputs.tag }}" + echo "**Platform**: ${{ inputs.platform }}" + echo "**Distribution**: ${{ inputs.distribution }}" + echo "**Profile**: ${{ inputs.profile }}" + echo "" + echo "### Version Info" + echo "- **Marketing Version**: ${{ needs.prepare-versions.outputs.marketing-version }}" + echo "- **Android versionCode**: ${{ needs.prepare-versions.outputs.android-version-code }}" + echo "- **iOS buildNumber**: ${{ needs.prepare-versions.outputs.ios-build-number }}" + echo "" + echo "### Build Results" + echo "" + + if [[ "${{ inputs.platform }}" == "android" || "${{ inputs.platform }}" == "both" ]]; then + if [[ "${{ needs.build-android.result }}" == "success" ]]; then + echo "[OK] Android: Built successfully" + else + echo "[FAIL] Android: ${{ needs.build-android.result }}" + fi + fi + + if [[ "${{ inputs.platform }}" == "ios" || "${{ inputs.platform }}" == "both" ]]; then + if [[ "${{ needs.build-ios.result }}" == "success" ]]; then + echo "[OK] iOS: Built successfully" + else + echo "[FAIL] iOS: ${{ needs.build-ios.result }}" + fi + fi + + echo "" + echo "### Upload and Submit" + if [[ "${{ needs.upload-and-submit.result }}" == "success" ]]; then + echo "[OK] Uploaded to EAS" + if [[ "${{ inputs.distribution }}" == "store" ]]; then + echo "[OK] Submitted to stores" + else + echo "[INFO] Internal distribution - check Expo dashboard for download links" + fi + else + echo "[WARN] Upload/Submit: ${{ needs.upload-and-submit.result }}" + fi + } >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml new file mode 100644 index 0000000..3d90471 --- /dev/null +++ b/.github/workflows/mobile-release.yml @@ -0,0 +1,314 @@ +name: Mobile Release + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to deploy (e.g., v1.2.3-beta.1)' + required: true + type: string + +concurrency: + group: mobile-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Check if this is a prerelease (alpha, beta, rc) + check-release-type: + name: Check Release Type + runs-on: ubuntu-latest + outputs: + prerelease: ${{ steps.check.outputs.prerelease }} + previous_tag: ${{ steps.prev_tag.outputs.tag }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.tag }} + + - name: Check if prerelease + id: check + run: | + TAG="${{ inputs.tag }}" + if [[ "$TAG" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "This is a prerelease: $TAG" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "This is a stable release: $TAG" + fi + + - name: Get previous tag + id: prev_tag + run: | + TAG="${{ inputs.tag }}" + PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") + echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT" + echo "Previous tag: $PREV_TAG" + + # Check if mobile code changed since last tag + check-mobile-changes: + name: Check Mobile Changes + runs-on: ubuntu-latest + needs: [check-release-type] + if: needs.check-release-type.outputs.prerelease == 'true' + outputs: + mobile_changed: ${{ steps.check.outputs.changed }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.tag }} + + - name: Check for mobile changes + id: check + run: | + PREV_TAG="${{ needs.check-release-type.outputs.previous_tag }}" + CURRENT_TAG="${{ inputs.tag }}" + + if [ -z "$PREV_TAG" ]; then + echo "No previous tag found, assuming mobile changed" + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check if any files in apps/mobile or shared packages changed + CHANGES=$(git diff --name-only "$PREV_TAG" "$CURRENT_TAG" -- apps/mobile/ packages/shared/ | wc -l) + + if [ "$CHANGES" -gt 0 ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Mobile changes detected: $CHANGES files" + git diff --name-only "$PREV_TAG" "$CURRENT_TAG" -- apps/mobile/ packages/shared/ + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No mobile changes since $PREV_TAG" + fi + + # Deploy mobile app using fingerprint-based detection + # This action automatically: + # 1. Calculates the native fingerprint + # 2. Checks if a compatible build already exists + # 3. If no build exists → triggers eas build + # 4. If build exists → reuses it + # 5. Pushes OTA update to the channel + deploy: + name: Deploy Mobile + runs-on: ubuntu-latest + needs: [check-release-type, check-mobile-changes] + if: | + needs.check-release-type.outputs.prerelease == 'true' && + needs.check-mobile-changes.outputs.mobile_changed == 'true' + timeout-minutes: 60 + defaults: + run: + working-directory: apps/mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Build shared package + run: pnpm --filter @tracearr/shared build + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Validate secrets + run: | + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "::error::EXPO_TOKEN secret is not set" + exit 1 + fi + + - name: Sync version from git tag + run: | + # Extract version from tag (v1.2.3-alpha.4 -> 1.2.3) + VERSION="${{ inputs.tag }}" + VERSION="${VERSION#v}" # Remove 'v' prefix + VERSION="${VERSION%%-*}" # Remove prerelease suffix (-alpha.4, -beta.1, etc.) + echo "Setting app version to: $VERSION" + # Update app.json with the version + jq --arg v "$VERSION" '.expo.version = $v' app.json > tmp.json && mv tmp.json app.json + # Verify the change + ACTUAL=$(jq -r '.expo.version' app.json) + if [ "$ACTUAL" != "$VERSION" ]; then + echo "::error::Version sync failed. Expected $VERSION, got $ACTUAL" + exit 1 + fi + echo "Version synced: $ACTUAL" + + - name: Decode Google Service Account + run: | + mkdir -p credentials + echo "${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}" | base64 -d > credentials/google-service-account.json + + - name: Deploy with fingerprint + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + id: deploy + with: + profile: preview + branch: preview + working-directory: apps/mobile + auto-submit-builds: true + env: + EXPO_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.EXPO_APPLE_APP_SPECIFIC_PASSWORD }} + GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} + + - name: Output deployment info + run: | + echo "## Deployment Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Fingerprint**: \`${{ steps.deploy.outputs.fingerprint }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "**Build needed**: ${{ steps.deploy.outputs.build-new-build }}" >> "$GITHUB_STEP_SUMMARY" + if [ -n "${{ steps.deploy.outputs.ios-build-id }}" ]; then + echo "**iOS Build ID**: ${{ steps.deploy.outputs.ios-build-id }}" >> "$GITHUB_STEP_SUMMARY" + fi + if [ -n "${{ steps.deploy.outputs.android-build-id }}" ]; then + echo "**Android Build ID**: ${{ steps.deploy.outputs.android-build-id }}" >> "$GITHUB_STEP_SUMMARY" + fi + if [ -n "${{ steps.deploy.outputs.update-id }}" ]; then + echo "**Update ID**: ${{ steps.deploy.outputs.update-id }}" >> "$GITHUB_STEP_SUMMARY" + fi + + # Fallback: Manual OTA if fingerprint action has issues + # This job only runs if deploy fails and we want to try OTA anyway + ota-fallback: + name: OTA Fallback + runs-on: ubuntu-latest + needs: [check-release-type, check-mobile-changes, deploy] + if: | + always() && + needs.deploy.result == 'failure' && + needs.check-release-type.outputs.prerelease == 'true' && + needs.check-mobile-changes.outputs.mobile_changed == 'true' + timeout-minutes: 15 + defaults: + run: + working-directory: apps/mobile + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + + - name: Build shared package + run: pnpm --filter @tracearr/shared build + + - name: Setup Expo + uses: expo/expo-github-action@main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Push OTA update with retry + run: | + TAG="${{ inputs.tag }}" + echo "::warning::Deploy failed, attempting OTA fallback for $TAG" + + for attempt in 1 2 3; do + echo "Attempt $attempt of 3..." + if eas update \ + --channel preview \ + --message "Release $TAG (fallback)" \ + --non-interactive; then + echo "OTA update successful on attempt $attempt" + exit 0 + fi + if [ $attempt -lt 3 ]; then + echo "Attempt $attempt failed, waiting before retry..." + sleep $((30 * attempt)) + fi + done + + echo "::error::OTA update failed after 3 attempts" + exit 1 + + # Summary job that reports status + summary: + name: Release Summary + runs-on: ubuntu-latest + needs: [check-release-type, check-mobile-changes, deploy, ota-fallback] + if: always() + + steps: + - name: Report status + env: + TAG: ${{ inputs.tag }} + PRERELEASE: ${{ needs.check-release-type.outputs.prerelease }} + MOBILE_CHANGED: ${{ needs.check-mobile-changes.outputs.mobile_changed || 'skipped' }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + FALLBACK_RESULT: ${{ needs.ota-fallback.result }} + run: | + { + echo "## Mobile Release Summary" + echo "" + echo "**Tag**: $TAG" + echo "**Prerelease**: $PRERELEASE" + echo "**Mobile Changed**: $MOBILE_CHANGED" + echo "" + + if [[ "$PRERELEASE" != "true" ]]; then + echo "ℹ️ Skipped: Not a prerelease (stable releases don't trigger mobile builds)" + elif [[ "$MOBILE_CHANGED" != "true" ]]; then + echo "ℹ️ Skipped: No mobile code changes detected" + else + echo "### Deployment Status" + echo "" + if [[ "$DEPLOY_RESULT" == "success" ]]; then + echo "✅ **Deploy**: Success" + echo "" + echo "The fingerprint-based deployment completed successfully." + echo "- If native changes were detected, a new build was triggered" + echo "- If only JS changes, an OTA update was pushed" + echo "" + echo "Check the [Expo dashboard](https://expo.dev) for details." + elif [[ "$FALLBACK_RESULT" == "success" ]]; then + echo "⚠️ **Deploy**: Failed (fingerprint action issue)" + echo "✅ **OTA Fallback**: Success" + echo "" + echo "The main deploy failed but OTA fallback succeeded." + echo "Users will receive the JS update on next app launch." + else + echo "❌ **Deploy**: $DEPLOY_RESULT" + echo "❌ **OTA Fallback**: $FALLBACK_RESULT" + echo "" + echo "Both deployment methods failed. Check workflow logs for details." + fi + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..b01b6d3 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,137 @@ +name: Nightly + +on: + schedule: + # Run at 4am UTC daily + - cron: '0 4 * * *' + workflow_dispatch: # Allow manual trigger + +concurrency: + group: nightly + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + # github.repository must be lowercased for Docker registry compatibility + IMAGE_NAME: ${{ github.repository_owner }}/tracearr + +jobs: + check-changes: + name: Check for changes + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check.outputs.should_build }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if new commits since last nightly + id: check + run: | + # Get commits in last 25 hours (buffer for timing) + COMMITS=$(git log --since="25 hours ago" --oneline | wc -l) + if [ "$COMMITS" -gt 0 ]; then + echo "should_build=true" >> "$GITHUB_OUTPUT" + echo "Found $COMMITS new commits" + else + echo "should_build=false" >> "$GITHUB_OUTPUT" + echo "No new commits, skipping build" + fi + + build-and-push: + name: Build & Push Docker + runs-on: ubuntu-latest + needs: [check-changes] + if: needs.check-changes.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=nightly + type=raw,value=nightly-{{date 'YYYYMMDD'}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-and-push-supervised: + name: Build & Push Supervised Image + runs-on: ubuntu-latest + needs: [check-changes] + if: needs.check-changes.outputs.should_build == 'true' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for supervised image + id: meta-supervised + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=supervised-nightly + type=raw,value=supervised-nightly-{{date 'YYYYMMDD'}} + + - name: Build and push supervised image + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile.supervised + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-supervised.outputs.tags }} + labels: ${{ steps.meta-supervised.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..060ed21 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,455 @@ +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + # github.repository must be lowercased for Docker registry compatibility + IMAGE_NAME: ${{ github.repository_owner }}/tracearr + +jobs: + # Run the same checks as CI + lint-and-typecheck: + name: Lint & Typecheck + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + test: + name: Test (${{ matrix.group }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + + strategy: + fail-fast: false + matrix: + group: [unit, services, routes, security] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dependencies (shared, test-utils) + run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils + + - name: Run ${{ matrix.group }} tests + run: pnpm --filter @tracearr/server test:${{ matrix.group }} + + # Integration tests require real database - run separately from unit tests + test-integration: + name: Test (integration) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TEST_DATABASE_URL: postgresql://test:test@localhost:5433/tracearr_test + TEST_REDIS_URL: redis://localhost:6380 + + services: + timescale: + image: timescale/timescaledb:latest-pg15 + ports: + - 5433:5432 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: tracearr_test + options: >- + --health-cmd "pg_isready -U test -d tracearr_test" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + redis: + image: redis:7-alpine + ports: + - 6380:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dependencies (shared, test-utils) + run: pnpm turbo run build --filter=@tracearr/shared --filter=@tracearr/test-utils + + - name: Run integration tests + run: pnpm --filter @tracearr/server test:integration + + # Determine if this is a prerelease (alpha, beta, rc, etc.) + check-release-type: + name: Check Release Type + runs-on: ubuntu-latest + needs: [lint-and-typecheck, test, test-integration] + outputs: + prerelease: ${{ steps.check.outputs.prerelease }} + + steps: + - name: Check if prerelease + id: check + run: | + TAG="${{ github.ref_name }}" + if [[ "$TAG" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "This is a prerelease: $TAG" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "This is a stable release: $TAG" + fi + + # Build each platform natively in parallel (much faster than QEMU emulation) + build-docker: + name: Build Docker (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + needs: [check-release-type] + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-docker-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Merge platform-specific images into multi-arch manifest + merge-docker: + name: Merge Docker Manifest + runs-on: ubuntu-latest + needs: [check-release-type, build-docker] + permissions: + contents: read + packages: write + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-docker-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}},enable=${{ needs.check-release-type.outputs.prerelease == 'false' }} + type=semver,pattern={{major}},enable=${{ needs.check-release-type.outputs.prerelease == 'false' }} + type=raw,value=next,enable=${{ needs.check-release-type.outputs.prerelease == 'true' }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + if [ -z "$(ls -A .)" ]; then + echo "Error: No digests found!" + exit 1 + fi + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + # Build supervised image on each platform + build-supervised: + name: Build Supervised (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + needs: [check-release-type] + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile.supervised + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=supervised-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=supervised-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-supervised-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Merge supervised multi-arch manifest + merge-supervised: + name: Merge Supervised Manifest + runs-on: ubuntu-latest + needs: [check-release-type, build-supervised] + permissions: + contents: read + packages: write + + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-supervised-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # Use flavor prefix for reliable tag prefixing + flavor: | + prefix=supervised-,onlatest=false + tags: | + type=semver,pattern={{version}} + type=raw,value=supervised,enable=${{ needs.check-release-type.outputs.prerelease == 'false' }},prefix= + type=raw,value=supervised-next,enable=${{ needs.check-release-type.outputs.prerelease == 'true' }},prefix= + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + if [ -z "$(ls -A .)" ]; then + echo "Error: No digests found!" + exit 1 + fi + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [check-release-type, merge-docker, merge-supervised] + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get tag annotation + id: tag_info + run: | + # Get the tag message (annotation) + TAG_MESSAGE=$(git tag -l --format='%(contents)' ${{ github.ref_name }}) + # Write to file to preserve newlines + echo "$TAG_MESSAGE" > tag_message.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: tag_message.txt + prerelease: ${{ needs.check-release-type.outputs.prerelease }} + generate_release_notes: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..930b7d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules +.pnpm-store + +# Build outputs +dist +build +.next +out + +# Turbo +.turbo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea +.vscode +*.swp +*.swo +.DS_Store + +# Testing +coverage + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Database (keep GeoLite2 database committed) +data/*.mmdb +!data/GeoLite2-City.mmdb + +# Local server data +apps/server/data/ + +# Docker +docker-compose.override.yml + +# Expo +.expo +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# Drizzle +drizzle-kit +*.tsbuildinfo diff --git a/.pnpmrc.json b/.pnpmrc.json new file mode 100644 index 0000000..1801a68 --- /dev/null +++ b/.pnpmrc.json @@ -0,0 +1 @@ +{"onlyBuiltDependencies": ["bcrypt", "esbuild", "sharp"]} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bcea586 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +build +.turbo +.next +coverage +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..79c446a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..668b21d --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 22.21.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cde3eb9 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +

+ Tracearr +

+ +

+ Know who's streaming. Catch account sharers. Take back control. +

+ +

+ CI Status + Nightly Build + Latest Release + Docker + License + Discord +

+ +--- + +Tracearr is a streaming access manager for **Plex**, **Jellyfin**, and **Emby** that answers one question: *Who's actually using my server, and are they sharing their login?* + +Unlike monitoring tools that just show you data, Tracearr is built to detect account abuse. See streams in real-time, flag suspicious activity automatically, and get notified the moment something looks off. + +## What It Does + +**Session Tracking** — Full history of who watched what, when, from where, on what device. Every stream logged with geolocation data. + +**Sharing Detection** — Five rule types catch account sharers: +- 🚀 **Impossible Travel** — NYC then London 30 minutes later? That's not one person. +- 📍 **Simultaneous Locations** — Same account streaming from two cities at once. +- 🔀 **Device Velocity** — Too many unique IPs in a short window signals shared credentials. +- 📺 **Concurrent Streams** — Set limits per user. Simple but effective. +- 🌍 **Geo Restrictions** — Block streaming from specific countries entirely. + +**Real-Time Alerts** — Discord webhooks and custom notifications fire instantly when rules trigger. No waiting for daily reports. + +**Stream Map** — Visualize where your streams originate on an interactive world map. Filter by user, server, or time period to zero in on suspicious patterns. + +**Trust Scores** — Users earn (or lose) trust based on their behavior. Violations drop scores automatically. + +**Multi-Server** — Connect Plex, Jellyfin, and Emby instances to the same dashboard. Manage everything in one place. + +**Tautulli Import** — Already using Tautulli? Import your watch history so you don't start from scratch. + +## What It Doesn't Do (Yet) + +Tracearr v1 is focused on **detection and alerting**. Automated enforcement—killing streams, suspending accounts—is coming in future versions. For now, you see the problems; you decide the action. + +## Why Not Tautulli or Jellystat? + +[Tautulli](https://github.com/Tautulli/Tautulli) and [Jellystat](https://github.com/CyferShepard/Jellystat) are great monitoring tools. We use Highcharts for graphs too. But they show you what happened—they don't tell you when something's wrong. + +| | Tautulli | Jellystat | Tracearr | +|---|---|---|---| +| Watch history | ✅ | ✅ | ✅ | +| Statistics & graphs | ✅ | ✅ | ✅ | +| Session monitoring | ✅ | ✅ | ✅ | +| Account sharing detection | ❌ | ❌ | ✅ | +| Impossible travel alerts | ❌ | ❌ | ✅ | +| Trust scoring | ❌ | ❌ | ✅ | +| Plex support | ✅ | ❌ | ✅ | +| Jellyfin support | ❌ | ✅ | ✅ | +| Emby support | ❌ | ✅ | ✅ | +| Multi-server dashboard | ❌ | ❌ | ✅ | +| IP geolocation | ✅ | ✅ | ✅ | +| Import from Tautulli | — | ❌ | ✅ | + +Tautulli and Jellystat are platform-locked equivalents—Plex-only vs Jellyfin/Emby-only. If you just want stats, they work fine. If you're tired of your brother's roommate's cousin streaming on your dime, that's what Tracearr is for. + +## Quick Start + +### Option 1: All-in-One (Recommended) + +The supervised image bundles TimescaleDB, Redis, and Tracearr in a single container. No external database required. Secrets are auto-generated on first run. + +```bash +docker compose -f docker/docker-compose.supervised.yml up -d +``` + +Open `http://localhost:3000` and connect your Plex, Jellyfin, or Emby server. + +### Option 2: Separate Services + +If you already have TimescaleDB/PostgreSQL and Redis, or prefer managing services separately: + +```bash +# Copy and configure environment +cp docker/.env.example docker/.env +# Edit docker/.env with your secrets (generate with: openssl rand -hex 32) + +docker compose -f docker/docker-compose.yml up -d +``` + +### Docker Tags + +| Tag | Description | +|-----|-------------| +| `latest` | Stable release (requires external DB/Redis) | +| `supervised` | All-in-one stable release | +| `next` | Latest prerelease | +| `supervised-nightly` | All-in-one nightly build | +| `nightly` | Bleeding edge nightly | + +```bash +# All-in-one (easiest) +docker pull ghcr.io/connorgallopo/tracearr:supervised + +# Stable (requires external services) +docker pull ghcr.io/connorgallopo/tracearr:latest + +# Living on the edge +docker pull ghcr.io/connorgallopo/tracearr:nightly +``` + +### Development Setup + +```bash +# Install dependencies (requires pnpm 10+, Node.js 22+) +pnpm install + +# Start database services +docker compose -f docker/docker-compose.dev.yml up -d + +# Copy and configure environment +cp .env.example .env + +# Run migrations +pnpm --filter @tracearr/server db:migrate + +# Start dev servers +pnpm dev +``` + +Frontend runs at `localhost:5173`, API at `localhost:3000`. + +## Stack + +| Layer | Tech | +|---|---| +| Frontend | React 19, TypeScript, Tailwind, shadcn/ui | +| Charts | Highcharts | +| Maps | Leaflet | +| Backend | Node.js, Fastify | +| Database | TimescaleDB (PostgreSQL extension) | +| Cache | Redis | +| Real-time | Socket.io | +| Monorepo | pnpm + Turborepo | + +**TimescaleDB** handles session history. Regular Postgres works fine until you have a year of watch data and your stats queries start taking forever. TimescaleDB is built for exactly this kind of time-series data—dashboard stats stay fast because they're pre-computed, not recalculated every page load. + +**Fastify** over Express because it's measurably faster and the schema validation is nice. + +**Plex SSE** — Plex servers stream session updates in real-time via Server-Sent Events. No polling delay, instant detection. Jellyfin and Emby still use polling (they don't support SSE), but Plex sessions appear the moment they start. + +## Project Structure + +``` +tracearr/ +├── apps/ +│ ├── web/ # React frontend +│ ├── server/ # Fastify backend +│ └── mobile/ # React Native app (coming soon) +├── packages/ +│ └── shared/ # Types, schemas, constants +├── docker/ # Compose files +└── docs/ # Documentation +``` + +## Configuration + +Tracearr uses environment variables for configuration. Key settings: + +```bash +# Database +DATABASE_URL=postgresql://user:pass@localhost:5432/tracearr + +# Redis +REDIS_URL=redis://localhost:6379 + +# Security +JWT_SECRET=your-secret-here +COOKIE_SECRET=your-cookie-secret-here + +# GeoIP (optional, for location detection) +MAXMIND_LICENSE_KEY=your-maxmind-key +``` + +See `.env.example` for all options. + +## Community + +Got questions? Found a bug? Want to contribute? + +[![Discord](https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/a7n3sFd2Yw) + +Or [open an issue](https://github.com/connorgallopo/Tracearr/issues) on GitHub. + +## Contributing + +Contributions welcome. Please: + +1. Fork the repo +2. Create a feature branch (`git checkout -b feature/thing`) +3. Make your changes +4. Run tests and linting (`pnpm test && pnpm lint`) +5. Open a PR + +Check the [issues](https://github.com/connorgallopo/Tracearr/issues) for things to work on. + +## Roadmap + +**Alpha** (current — v0.1.x) +- [x] Multi-server Plex, Jellyfin, and Emby support +- [x] Session tracking with full history +- [x] 5 sharing detection rules +- [x] Real-time WebSocket updates +- [x] Plex SSE for instant session detection +- [x] Discord + webhook notifications +- [x] Interactive stream map +- [x] Trust scores +- [x] Tautulli history import + +**v1.5** (next milestone) +- [ ] Mobile app (iOS & Android) — *in development* +- [ ] Stream termination (kill suspicious streams) +- [ ] Account suspension automation +- [ ] Email notifications +- [ ] Telegram notifier + +**v2.0** (future) +- [ ] Tiered access controls +- [ ] Arr integration (Radarr/Sonarr) +- [ ] Multi-admin support + +## License + +[AGPL-3.0](LICENSE) — Open source with copyleft protection. If you modify Tracearr and offer it as a service, you share your changes. + +--- + +

+ Built because sharing is caring, but not when it's your server bill. +

diff --git a/RELEASE_NOTES_1.2.md b/RELEASE_NOTES_1.2.md new file mode 100644 index 0000000..73ce6c8 --- /dev/null +++ b/RELEASE_NOTES_1.2.md @@ -0,0 +1,127 @@ +# Tracearr v1.2 — First Stable Release + +After months of alpha and beta testing, Tracearr is officially stable. Thanks to everyone who's been running the prereleases, reporting bugs, and helping shape this thing. + +## What is Tracearr? + +Tracearr answers one question: **Who's actually using your media server, and are they sharing their login?** + +It's not just another stats dashboard. Tracearr is built specifically to detect account abuse across Plex, Jellyfin, and Emby servers — impossible travel, simultaneous locations, device velocity, you name it. + +--- + +## Highlights + +### All-in-One Docker Image + +The new `supervised` image bundles everything you need — TimescaleDB, Redis, and Tracearr — in a single container. No external database setup required. Secrets are auto-generated on first run. + +```bash +docker pull ghcr.io/connorgallopo/tracearr:supervised +``` + +Available on [Unraid Community Apps](https://github.com/connorgallopo/tracearr-unraid-template) too. + +### Mobile App + +iOS and Android companion app with: +- Real-time session monitoring +- Push notifications with quiet hours +- Interactive stream map +- QR code pairing (no manual URL entry) + +### Plex Server-Sent Events + +Plex sessions appear instantly via SSE — no more polling delays. Fallback to polling if your server doesn't support it. + +### Multi-Server Support + +Connect all your Plex, Jellyfin, and Emby servers to one install. See everything in one place. + +### Dark & Light Mode + +Full theme support — switch between dark and light mode based on your preference or system settings. + +### Custom Date Filtering + +Filter stats and activity by custom date ranges. Pick any start and end date, not just the preset week/month/year options. + +--- + +## Features + +### Sharing Detection +- **Impossible Travel** — Same account in NYC then London 30 minutes later? Flagged. +- **Simultaneous Locations** — Streaming from two cities at once? Caught. +- **Device Velocity** — Too many unique IPs in a short window? Suspicious. +- **Concurrent Streams** — Set per-user limits. +- **Geo Restrictions** — Block specific countries entirely. + +### Session Tracking +- Full watch history with geolocation +- Device and player info +- Pause duration tracking +- Progress estimation via Plex SSE + +### Trust Scores +- Users earn (or lose) trust based on behavior +- Violations automatically drop scores +- Visual trust indicators in the dashboard + +### Notifications +- Discord webhooks +- Custom webhook endpoints +- Push notifications to mobile app +- Per-channel routing +- Quiet hours and rate limiting + +### Import & Migration +- Tautulli history import — don't start from scratch +- Bring your existing watch data + +### Stream Map +- Interactive world map showing stream origins +- Filter by user, server, or time period +- Spot geographic anomalies at a glance + +--- + +## Docker Tags + +| Tag | Description | +|-----|-------------| +| `latest` | Stable release (requires external DB/Redis) | +| `supervised` | **All-in-one stable** — just works | +| `1.2.0` | This specific version | +| `supervised-1.2.0` | This specific version (all-in-one) | + +--- + +## What's New Since Beta + +- **Supervised image improvements** — non-root user, log rotation, timezone support, boot loop fixes +- **Better Unraid support** — auto-creates directories, handles corrupt data gracefully +- **Removed dead code** — cleaned up unused TimescaleDB aggregates +- **Mobile navigation** — proper tab navigation after first-time pairing + +--- + +## What's Next + +- Stream termination (kill suspicious sessions remotely) +- Account suspension automation +- Email and Telegram notifications +- Multi-admin support + +--- + +## Links + +- [Documentation](https://github.com/connorgallopo/Tracearr#readme) +- [Discord](https://discord.gg/a7n3sFd2Yw) +- [Report Issues](https://github.com/connorgallopo/Tracearr/issues) +- Mobile apps coming to App Store and Play Store + +--- + +Built because sharing is caring — but not when it's your server bill. diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..f801d4a --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,8 @@ +# Tracearr Mobile Environment Variables +# Copy this file to .env and fill in your values + +# Your Tracearr server URL (set during QR pairing, but can override for development) +# EXPO_PUBLIC_API_URL=http://localhost:3000 + +# EAS Project ID (from expo.dev) +EXPO_PUBLIC_PROJECT_ID=your-eas-project-id diff --git a/apps/mobile/.eslintignore b/apps/mobile/.eslintignore new file mode 100644 index 0000000..0a8a2cf --- /dev/null +++ b/apps/mobile/.eslintignore @@ -0,0 +1,5 @@ +babel.config.js +metro.config.js +postcss.config.mjs +.expo/ +plugins/ diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..697c734 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,45 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env +.env*.local + +# credentials (generated in CI) +credentials/ + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js new file mode 100644 index 0000000..2955376 --- /dev/null +++ b/apps/mobile/app.config.js @@ -0,0 +1,34 @@ +/** + * Dynamic Expo config that extends app.json + * Allows injecting secrets from environment variables at build time + */ + +const baseConfig = require('./app.json'); + +module.exports = ({ config }) => { + // Merge base config with dynamic values + return { + ...baseConfig.expo, + ...config, + plugins: [ + // Keep existing plugins but update expo-maps with API key from env + ...baseConfig.expo.plugins.map((plugin) => { + // Handle expo-maps plugin + if (Array.isArray(plugin) && plugin[0] === 'expo-maps') { + return [ + 'expo-maps', + { + ...plugin[1], + android: { + ...plugin[1]?.android, + // Inject Google Maps API key from EAS secrets or env var + googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY || '', + }, + }, + ]; + } + return plugin; + }), + ], + }; +}; diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..09686d2 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,102 @@ +{ + "expo": { + "name": "Tracearr", + "slug": "tracearr", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "scheme": "tracearr", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon-transparent.png", + "resizeMode": "contain", + "backgroundColor": "#051723" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.tracearr.app", + "infoPlist": { + "UIBackgroundModes": [ + "remote-notification", + "fetch" + ], + "NSCameraUsageDescription": "Tracearr needs camera access to scan QR codes for server pairing.", + "NSLocationWhenInUseUsageDescription": "Location permission is not required for this app.", + "ITSAppUsesNonExemptEncryption": false, + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true, + "NSAllowsLocalNetworking": true + }, + "NSLocalNetworkUsageDescription": "Tracearr needs local network access to connect to your self-hosted server." + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon-transparent.png", + "backgroundColor": "#051723" + }, + "edgeToEdgeEnabled": true, + "package": "com.tracearr.app", + "permissions": [ + "RECEIVE_BOOT_COMPLETED", + "VIBRATE", + "android.permission.CAMERA", + "android.permission.POST_NOTIFICATIONS" + ] + }, + "plugins": [ + [ + "./plugins/withGradleProperties", + { + "org.gradle.jvmargs": "-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8", + "org.gradle.daemon": "false" + } + ], + "expo-router", + [ + "expo-notifications", + { + "icon": "./assets/notification-icon.png", + "color": "#18D1E7", + "defaultChannel": "violations", + "enableBackgroundRemoteNotifications": true + } + ], + [ + "expo-camera", + { + "cameraPermission": "Allow Tracearr to access your camera for QR code scanning.", + "recordAudioAndroid": false + } + ], + [ + "expo-maps", + { + "requestLocationPermission": false + } + ], + "expo-secure-store", + "react-native-quick-crypto" + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "router": { + "origin": false + }, + "eas": { + "projectId": "2e0b9595-ac62-493f-9a10-4f8758bb4b2d" + }, + "buildNumber": "1" + }, + "owner": "gallopo-solutions", + "runtimeVersion": { + "policy": "fingerprint" + }, + "updates": { + "url": "https://u.expo.dev/2e0b9595-ac62-493f-9a10-4f8758bb4b2d" + } + } +} diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..1718cb2 --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,18 @@ +/** + * Auth layout - for login/pairing screens + */ +import { Stack } from 'expo-router'; +import { colors } from '@/lib/theme'; + +export default function AuthLayout() { + return ( + + + + ); +} diff --git a/apps/mobile/app/(auth)/pair.tsx b/apps/mobile/app/(auth)/pair.tsx new file mode 100644 index 0000000..61bee2e --- /dev/null +++ b/apps/mobile/app/(auth)/pair.tsx @@ -0,0 +1,408 @@ +/** + * Pairing screen - QR code scanner or manual entry + * Supports both initial pairing and adding additional servers + */ +import { useState, useRef } from 'react'; +import { + View, + Text, + TextInput, + Pressable, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { ChevronLeft } from 'lucide-react-native'; +import { useAuthStore } from '@/lib/authStore'; +import { colors, spacing, borderRadius, typography } from '@/lib/theme'; + +interface QRPairingPayload { + url: string; + token: string; +} + +export default function PairScreen() { + const router = useRouter(); + const [permission, requestPermission] = useCameraPermissions(); + const [manualMode, setManualMode] = useState(false); + const [serverUrl, setServerUrl] = useState(''); + const [token, setToken] = useState(''); + const [scanned, setScanned] = useState(false); + const scanLockRef = useRef(false); // Synchronous lock to prevent race conditions + + const { addServer, isAuthenticated, servers, isLoading, error, clearError } = useAuthStore(); + + // Check if this is adding an additional server vs first-time pairing + const isAddingServer = isAuthenticated && servers.length > 0; + + const handleBarCodeScanned = async ({ data }: { data: string }) => { + // Use ref for synchronous check - state updates are async and cause race conditions + if (scanLockRef.current || isLoading) return; + scanLockRef.current = true; // Immediate synchronous lock + setScanned(true); + + try { + // Parse tracearr://pair?data= + // First check if it even looks like our URL format + if (!data.startsWith('tracearr://pair')) { + // Silently ignore non-Tracearr QR codes (don't spam alerts) + setTimeout(() => { + scanLockRef.current = false; + setScanned(false); + }, 2000); + return; + } + + const url = new URL(data); + const base64Data = url.searchParams.get('data'); + if (!base64Data) { + throw new Error('Invalid QR code: missing pairing data'); + } + + // Decode and parse payload with proper error handling + let payload: QRPairingPayload; + try { + const decoded = atob(base64Data); + payload = JSON.parse(decoded) as QRPairingPayload; + } catch { + throw new Error('Invalid QR code format. Please generate a new code.'); + } + + // Validate payload has required fields + if (!payload.url || typeof payload.url !== 'string') { + throw new Error('Invalid QR code: missing server URL'); + } + if (!payload.token || typeof payload.token !== 'string') { + throw new Error('Invalid QR code: missing pairing token'); + } + + // Validate URL format + if (!payload.url.startsWith('http://') && !payload.url.startsWith('https://')) { + throw new Error('Invalid server URL in QR code'); + } + + await addServer(payload.url, payload.token); + + // Navigate to tabs after successful pairing + router.replace('/(tabs)'); + } catch (err) { + Alert.alert('Pairing Failed', err instanceof Error ? err.message : 'Invalid QR code'); + // Add cooldown before allowing another scan + setTimeout(() => { + scanLockRef.current = false; + setScanned(false); + }, 3000); + } + }; + + const handleManualPair = async () => { + if (!serverUrl.trim() || !token.trim()) { + Alert.alert('Missing Fields', 'Please enter both server URL and token'); + return; + } + + const trimmedUrl = serverUrl.trim(); + + // Validate URL format + if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) { + Alert.alert('Invalid URL', 'Server URL must start with http:// or https://'); + return; + } + + // Validate URL is well-formed + try { + new URL(trimmedUrl); + } catch { + Alert.alert('Invalid URL', 'Please enter a valid server URL'); + return; + } + + clearError(); + try { + await addServer(trimmedUrl, token.trim()); + + // Navigate to tabs after successful pairing + router.replace('/(tabs)'); + } catch { + // Error is handled by the store + } + }; + + const handleBack = () => { + router.back(); + }; + + if (manualMode) { + return ( + + + + {/* Back button for adding servers */} + {isAddingServer && ( + + + Back + + )} + + + + {isAddingServer ? 'Add Server' : 'Connect to Server'} + + + Enter your Tracearr server URL and mobile access token + + + + + + Server URL + + + + + Access Token + + + + {error && {error}} + + + + {isLoading ? 'Connecting...' : 'Connect'} + + + + setManualMode(false)} + disabled={isLoading} + > + Scan QR Code Instead + + + + + + ); + } + + return ( + + {/* Back button for adding servers */} + {isAddingServer && ( + + + Back + + )} + + + + {isAddingServer ? 'Add Server' : 'Welcome to Tracearr'} + + + Open Settings → Mobile App in your Tracearr dashboard and scan the QR code + + + + + {permission?.granted ? ( + + + + + + ) : ( + + + Camera permission is required to scan QR codes + + + Grant Permission + + + )} + + + + setManualMode(true)}> + Enter URL and Token Manually + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.dark, + }, + keyboardView: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + padding: spacing.lg, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginTop: spacing.sm, + }, + backText: { + fontSize: typography.fontSize.base, + color: colors.text.primary.dark, + marginLeft: spacing.xs, + }, + header: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, + paddingBottom: spacing.lg, + alignItems: 'center', + }, + title: { + fontSize: typography.fontSize['2xl'], + fontWeight: 'bold', + color: colors.text.primary.dark, + marginBottom: spacing.sm, + textAlign: 'center', + }, + subtitle: { + fontSize: typography.fontSize.base, + color: colors.text.secondary.dark, + textAlign: 'center', + lineHeight: 22, + }, + cameraContainer: { + flex: 1, + marginHorizontal: spacing.lg, + marginBottom: spacing.lg, + borderRadius: borderRadius.xl, + overflow: 'hidden', + backgroundColor: colors.card.dark, + }, + camera: { + flex: 1, + }, + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.3)', + }, + scanFrame: { + width: 250, + height: 250, + borderWidth: 2, + borderColor: colors.cyan.core, + borderRadius: borderRadius.lg, + backgroundColor: 'transparent', + }, + permissionContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + permissionText: { + fontSize: typography.fontSize.base, + color: colors.text.secondary.dark, + textAlign: 'center', + marginBottom: spacing.lg, + }, + footer: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + alignItems: 'center', + }, + form: { + flex: 1, + gap: spacing.md, + }, + inputGroup: { + gap: spacing.xs, + }, + label: { + fontSize: typography.fontSize.sm, + fontWeight: '500', + color: colors.text.secondary.dark, + }, + input: { + backgroundColor: colors.card.dark, + borderWidth: 1, + borderColor: colors.border.dark, + borderRadius: borderRadius.md, + padding: spacing.md, + fontSize: typography.fontSize.base, + color: colors.text.primary.dark, + }, + button: { + backgroundColor: colors.cyan.core, + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.md, + alignItems: 'center', + marginTop: spacing.sm, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + fontSize: typography.fontSize.base, + fontWeight: '600', + color: colors.blue.core, + }, + linkButton: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + linkText: { + fontSize: typography.fontSize.base, + color: colors.cyan.core, + }, + errorText: { + fontSize: typography.fontSize.sm, + color: colors.error, + textAlign: 'center', + }, +}); diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..2d61c48 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,116 @@ +/** + * Main tab navigation layout + */ +import { Tabs } from 'expo-router'; +import { + LayoutDashboard, + Activity, + Users, + Bell, + Settings, + type LucideIcon, +} from 'lucide-react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors } from '@/lib/theme'; +import { ServerSelector } from '@/components/ServerSelector'; + +interface TabIconProps { + icon: LucideIcon; + focused: boolean; +} + +function TabIcon({ icon: Icon, focused }: TabIconProps) { + return ( + + ); +} + +export default function TabLayout() { + const insets = useSafeAreaInsets(); + // Dynamic tab bar height: base height + safe area bottom inset + const tabBarHeight = 60 + insets.bottom; + + return ( + + , + tabBarLabel: 'Dashboard', + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + , + tabBarLabel: 'Activity', + tabBarIcon: ({ focused }) => ( + + ), + }} + /> + , + tabBarLabel: 'Users', + tabBarIcon: ({ focused }) => , + }} + /> + , + tabBarLabel: 'Alerts', + tabBarIcon: ({ focused }) => , + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/apps/mobile/app/(tabs)/activity.tsx b/apps/mobile/app/(tabs)/activity.tsx new file mode 100644 index 0000000..c249988 --- /dev/null +++ b/apps/mobile/app/(tabs)/activity.tsx @@ -0,0 +1,157 @@ +/** + * Activity tab - streaming statistics and charts + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { useState } from 'react'; +import { View, ScrollView, RefreshControl } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { colors } from '@/lib/theme'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { PeriodSelector, type StatsPeriod } from '@/components/ui/period-selector'; +import { + PlaysChart, + PlatformChart, + DayOfWeekChart, + HourOfDayChart, + QualityChart, +} from '@/components/charts'; + +function ChartSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +export default function ActivityScreen() { + const [period, setPeriod] = useState('month'); + const { selectedServerId } = useMediaServer(); + + // Fetch all stats data with selected period - query keys include selectedServerId for cache isolation + const { + data: playsData, + refetch: refetchPlays, + isRefetching: isRefetchingPlays, + } = useQuery({ + queryKey: ['stats', 'plays', period, selectedServerId], + queryFn: () => api.stats.plays({ period, serverId: selectedServerId ?? undefined }), + }); + + const { data: dayOfWeekData, refetch: refetchDayOfWeek } = useQuery({ + queryKey: ['stats', 'dayOfWeek', period, selectedServerId], + queryFn: () => api.stats.playsByDayOfWeek({ period, serverId: selectedServerId ?? undefined }), + }); + + const { data: hourOfDayData, refetch: refetchHourOfDay } = useQuery({ + queryKey: ['stats', 'hourOfDay', period, selectedServerId], + queryFn: () => api.stats.playsByHourOfDay({ period, serverId: selectedServerId ?? undefined }), + }); + + const { data: platformsData, refetch: refetchPlatforms } = useQuery({ + queryKey: ['stats', 'platforms', period, selectedServerId], + queryFn: () => api.stats.platforms({ period, serverId: selectedServerId ?? undefined }), + }); + + const { data: qualityData, refetch: refetchQuality } = useQuery({ + queryKey: ['stats', 'quality', period, selectedServerId], + queryFn: () => api.stats.quality({ period, serverId: selectedServerId ?? undefined }), + }); + + const handleRefresh = () => { + void refetchPlays(); + void refetchDayOfWeek(); + void refetchHourOfDay(); + void refetchPlatforms(); + void refetchQuality(); + }; + + // Period labels for display + const periodLabels: Record = { + week: 'Last 7 Days', + month: 'Last 30 Days', + year: 'Last Year', + }; + + return ( + + + } + > + {/* Header with Period Selector */} + + + Activity + {periodLabels[period]} + + + + + {/* Plays Over Time */} + + + + + {/* Day of Week & Hour of Day in a row on larger screens */} + + + + By Day + + + + + + + + By Hour + + + + + {/* Platform Breakdown */} + + + + + {/* Quality Breakdown */} + + {qualityData ? ( + + ) : ( + + Loading... + + )} + + + + ); +} diff --git a/apps/mobile/app/(tabs)/alerts.tsx b/apps/mobile/app/(tabs)/alerts.tsx new file mode 100644 index 0000000..5947feb --- /dev/null +++ b/apps/mobile/app/(tabs)/alerts.tsx @@ -0,0 +1,326 @@ +/** + * Alerts tab - violations with infinite scroll + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { formatDistanceToNow } from 'date-fns'; +import { + MapPin, + Users, + Zap, + Monitor, + Globe, + AlertTriangle, + Check, + type LucideIcon, +} from 'lucide-react-native'; +import { api } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { UserAvatar } from '@/components/ui/user-avatar'; +import { colors } from '@/lib/theme'; +import type { ViolationWithDetails, RuleType, UnitSystem } from '@tracearr/shared'; +import { formatSpeed } from '@tracearr/shared'; + +const PAGE_SIZE = 50; + +// Rule type icons mapping +const ruleIcons: Record = { + impossible_travel: MapPin, + simultaneous_locations: Users, + device_velocity: Zap, + concurrent_streams: Monitor, + geo_restriction: Globe, +}; + +// Rule type display names +const ruleLabels: Record = { + impossible_travel: 'Impossible Travel', + simultaneous_locations: 'Simultaneous Locations', + device_velocity: 'Device Velocity', + concurrent_streams: 'Concurrent Streams', + geo_restriction: 'Geo Restriction', +}; + +// Format violation data into readable description based on rule type +function getViolationDescription(violation: ViolationWithDetails, unitSystem: UnitSystem = 'metric'): string { + const data = violation.data; + const ruleType = violation.rule?.type; + + if (!data || !ruleType) { + return 'Rule violation detected'; + } + + switch (ruleType) { + case 'impossible_travel': { + const from = data.fromCity || data.fromLocation || 'unknown location'; + const to = data.toCity || data.toLocation || 'unknown location'; + const speed = typeof data.calculatedSpeedKmh === 'number' + ? formatSpeed(data.calculatedSpeedKmh, unitSystem) + : 'impossible speed'; + return `Traveled from ${from} to ${to} at ${speed}`; + } + case 'simultaneous_locations': { + const locations = data.locations as string[] | undefined; + const count = data.locationCount as number | undefined; + if (locations && locations.length > 0) { + return `Active from ${locations.length} locations: ${locations.slice(0, 2).join(', ')}${locations.length > 2 ? '...' : ''}`; + } + if (count) { + return `Streaming from ${count} different locations simultaneously`; + } + return 'Streaming from multiple locations simultaneously'; + } + case 'device_velocity': { + const ipCount = data.ipCount as number | undefined; + const windowHours = data.windowHours as number | undefined; + if (ipCount && windowHours) { + return `${ipCount} different IPs used in ${windowHours}h window`; + } + return 'Too many unique devices in short period'; + } + case 'concurrent_streams': { + const streamCount = data.streamCount as number | undefined; + const maxStreams = data.maxStreams as number | undefined; + if (streamCount && maxStreams) { + return `${streamCount} concurrent streams (limit: ${maxStreams})`; + } + return 'Exceeded concurrent stream limit'; + } + case 'geo_restriction': { + const country = data.country as string | undefined; + const blockedCountry = data.blockedCountry as string | undefined; + if (country || blockedCountry) { + return `Streaming from blocked region: ${country || blockedCountry}`; + } + return 'Streaming from restricted location'; + } + default: + return 'Rule violation detected'; + } +} + +function SeverityBadge({ severity }: { severity: string }) { + const variant = + severity === 'critical' || severity === 'high' + ? 'destructive' + : severity === 'warning' + ? 'warning' + : 'default'; + + return ( + + {severity} + + ); +} + +function RuleIcon({ ruleType }: { ruleType: RuleType | undefined }) { + const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle; + return ( + + + + ); +} + +function ViolationCard({ + violation, + onAcknowledge, + onPress, + unitSystem, +}: { + violation: ViolationWithDetails; + onAcknowledge: () => void; + onPress: () => void; + unitSystem: UnitSystem; +}) { + const username = violation.user?.username || 'Unknown User'; + const ruleType = violation.rule?.type as RuleType | undefined; + const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule'; + const description = getViolationDescription(violation, unitSystem); + const timeAgo = formatDistanceToNow(new Date(violation.createdAt), { addSuffix: true }); + + return ( + + + {/* Header: User + Severity */} + + + + + {username} + {timeAgo} + + + + + + {/* Content: Rule Type with Icon + Description */} + + + + + {ruleName} + + + {description} + + + + + {/* Action Button */} + {!violation.acknowledgedAt ? ( + { + e.stopPropagation(); + onAcknowledge(); + }} + > + + Acknowledge + + ) : ( + + + Acknowledged + + )} + + + ); +} + +export default function AlertsScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { selectedServerId } = useMediaServer(); + + // Fetch settings for unit system preference + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.settings.get, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + const unitSystem = settings?.unitSystem ?? 'metric'; + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + isRefetching, + } = useInfiniteQuery({ + queryKey: ['violations', selectedServerId], + queryFn: ({ pageParam = 1 }) => + api.violations.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }), + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; totalPages: number }) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + }); + + const acknowledgeMutation = useMutation({ + mutationFn: api.violations.acknowledge, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['violations', selectedServerId] }); + }, + }); + + // Flatten all pages into single array + const violations = data?.pages.flatMap((page) => page.data) || []; + const unacknowledgedCount = violations.filter((v) => !v.acknowledgedAt).length; + const total = data?.pages[0]?.total || 0; + + const handleEndReached = () => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }; + + const handleViolationPress = (violation: ViolationWithDetails) => { + // Navigate to user detail page + if (violation.user?.id) { + router.push(`/user/${violation.user.id}` as never); + } + }; + + return ( + + item.id} + renderItem={({ item }) => ( + acknowledgeMutation.mutate(item.id)} + onPress={() => handleViolationPress(item)} + unitSystem={unitSystem} + /> + )} + contentContainerClassName="p-4 pt-3" + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + refreshControl={ + + } + ListHeaderComponent={ + + + Alerts + + {total} {total === 1 ? 'violation' : 'violations'} total + + + {unacknowledgedCount > 0 && ( + + + {unacknowledgedCount} pending + + + )} + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={ + + + + + All Clear + + No rule violations have been detected. Your users are behaving nicely! + + + } + /> + + ); +} diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..9a13888 --- /dev/null +++ b/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,208 @@ +/** + * Dashboard tab - overview of streaming activity + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { View, ScrollView, RefreshControl } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; +import { Ionicons } from '@expo/vector-icons'; +import { api } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { useServerStatistics } from '@/hooks/useServerStatistics'; +import { StreamMap } from '@/components/map/StreamMap'; +import { NowPlayingCard } from '@/components/sessions'; +import { ServerResourceCard } from '@/components/server/ServerResourceCard'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { colors } from '@/lib/theme'; + +/** + * Compact stat pill for dashboard summary bar + */ +function StatPill({ + icon, + value, + unit, + color = colors.text.secondary.dark, +}: { + icon: keyof typeof Ionicons.glyphMap; + value: string | number; + unit?: string; + color?: string; +}) { + return ( + + + + {value} + + {unit && ( + {unit} + )} + + ); +} + +export default function DashboardScreen() { + const router = useRouter(); + const { selectedServerId, selectedServer } = useMediaServer(); + + const { + data: stats, + refetch, + isRefetching, + } = useQuery({ + queryKey: ['dashboard', 'stats', selectedServerId], + queryFn: () => api.stats.dashboard(selectedServerId ?? undefined), + }); + + const { data: activeSessions } = useQuery({ + queryKey: ['sessions', 'active', selectedServerId], + queryFn: () => api.sessions.active(selectedServerId ?? undefined), + staleTime: 1000 * 15, // 15 seconds - match web + refetchInterval: 1000 * 30, // 30 seconds - fallback if WebSocket events missed + }); + + // Only show server resources for Plex servers + const isPlexServer = selectedServer?.type === 'plex'; + + // Poll server statistics only when dashboard is visible and we have a Plex server + const { + latest: serverResources, + isLoadingData: resourcesLoading, + error: resourcesError, + } = useServerStatistics(selectedServerId ?? undefined, isPlexServer); + + return ( + + + } + > + {/* Today's Stats Bar */} + {stats && ( + + + + TODAY + + + + 0 ? colors.warning : colors.text.muted.dark} + /> + + + )} + + {/* Now Playing - Active Streams */} + + + + + + Now Playing + + + {activeSessions && activeSessions.length > 0 && ( + + + {activeSessions.length} {activeSessions.length === 1 ? 'stream' : 'streams'} + + + )} + + {activeSessions && activeSessions.length > 0 ? ( + + {activeSessions.map((session) => ( + router.push(`/session/${session.id}` as never)} + /> + ))} + + ) : ( + + + + + + No active streams + Streams will appear here when users start watching + + + )} + + + {/* Stream Map - only show when there are active streams */} + {activeSessions && activeSessions.length > 0 && ( + + + + + Stream Locations + + + + + )} + + {/* Server Resources - only show if Plex server is active */} + {isPlexServer && ( + + + + + Server Resources + + + + + )} + + + + ); +} diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000..939acd9 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,299 @@ +/** + * Settings tab - notifications, app info + * Server selection is handled via the global header selector + */ +import { View, ScrollView, Pressable, Switch, Alert, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ChevronRight, LogOut } from 'lucide-react-native'; +import { useAuthStore } from '@/lib/authStore'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; +import { colors } from '@/lib/theme'; +import Constants from 'expo-constants'; + +function SettingsRow({ + label, + value, + onPress, + showChevron, + leftIcon, + rightIcon, + destructive, +}: { + label: string; + value?: string; + onPress?: () => void; + showChevron?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + destructive?: boolean; +}) { + const content = ( + + + {leftIcon && {leftIcon}} + {label} + + + {value && {value}} + {rightIcon} + {showChevron && ( + + )} + + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + return content; +} + +function SettingsToggle({ + label, + description, + value, + onValueChange, + disabled, + isLoading, +}: { + label: string; + description?: string; + value: boolean; + onValueChange: (value: boolean) => void; + disabled?: boolean; + isLoading?: boolean; +}) { + return ( + + + {label} + {description && ( + + {description} + + )} + + {isLoading ? ( + + ) : ( + + )} + + ); +} + +function SettingsSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +function Divider() { + return ; +} + +export default function SettingsScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { + activeServerId, + activeServer, + isLoading: isAuthLoading, + logout, + } = useAuthStore(); + + const appVersion = Constants.expoConfig?.version || '1.0.0'; + + // Fetch notification preferences + const { + data: preferences, + isLoading: isLoadingPrefs, + error: prefsError, + } = useQuery({ + queryKey: ['notifications', 'preferences'], + queryFn: api.notifications.getPreferences, + staleTime: 1000 * 60, // 1 minute + enabled: !!activeServerId, + }); + + // Update mutation for quick toggle + const updateMutation = useMutation({ + mutationFn: api.notifications.updatePreferences, + onMutate: async (newData) => { + await queryClient.cancelQueries({ + queryKey: ['notifications', 'preferences'], + }); + const previousData = queryClient.getQueryData([ + 'notifications', + 'preferences', + ]); + queryClient.setQueryData( + ['notifications', 'preferences'], + (old: typeof preferences) => (old ? { ...old, ...newData } : old) + ); + return { previousData }; + }, + onError: (_err, _newData, context) => { + if (context?.previousData) { + queryClient.setQueryData( + ['notifications', 'preferences'], + context.previousData + ); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ + queryKey: ['notifications', 'preferences'], + }); + }, + }); + + const handleDisconnect = () => { + Alert.alert( + 'Disconnect from Server', + 'Are you sure you want to disconnect? You will need to scan a QR code to reconnect.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: () => { + void (async () => { + await queryClient.cancelQueries(); + await logout(); + queryClient.clear(); + })(); + }, + }, + ] + ); + }; + + const handleTogglePush = (value: boolean) => { + updateMutation.mutate({ pushEnabled: value }); + }; + + const navigateToNotificationSettings = () => { + router.push('/settings/notifications'); + }; + + // Count enabled notification events for summary + const enabledEventCount = preferences + ? [ + preferences.onViolationDetected, + preferences.onStreamStarted, + preferences.onStreamStopped, + preferences.onConcurrentStreams, + preferences.onNewDevice, + preferences.onTrustScoreChanged, + preferences.onServerDown, + preferences.onServerUp, + ].filter(Boolean).length + : 0; + + return ( + + + {/* Connected Server Info */} + {activeServer && ( + + + + + + } + destructive + /> + + )} + + {/* Notification Settings */} + {activeServerId && ( + + {prefsError ? ( + + + Failed to load notification settings + + + ) : ( + <> + + + + + Configure which events trigger notifications, quiet hours, and filters. + + + )} + + )} + + {/* App Info */} + + + + + + + {/* Loading indicator */} + {isAuthLoading && ( + + + + )} + + + ); +} diff --git a/apps/mobile/app/(tabs)/users.tsx b/apps/mobile/app/(tabs)/users.tsx new file mode 100644 index 0000000..e7de305 --- /dev/null +++ b/apps/mobile/app/(tabs)/users.tsx @@ -0,0 +1,149 @@ +/** + * Users tab - user list with infinite scroll + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { api } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { UserAvatar } from '@/components/ui/user-avatar'; +import { cn } from '@/lib/utils'; +import { colors } from '@/lib/theme'; +import type { ServerUserWithIdentity } from '@tracearr/shared'; + +const PAGE_SIZE = 50; + +function TrustScoreBadge({ score }: { score: number }) { + const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success'; + + return ( + + + {score} + + + ); +} + +function UserCard({ user, onPress }: { user: ServerUserWithIdentity; onPress: () => void }) { + return ( + + + + + + {user.username} + + {user.role === 'owner' ? 'Owner' : 'User'} + + + + + + + ); +} + +export default function UsersScreen() { + const router = useRouter(); + const { selectedServerId } = useMediaServer(); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch, + isRefetching, + } = useInfiniteQuery({ + queryKey: ['users', selectedServerId], + queryFn: ({ pageParam = 1 }) => + api.users.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }), + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; totalPages: number }) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + }); + + // Flatten all pages into single array + const users = data?.pages.flatMap((page) => page.data) || []; + const total = data?.pages[0]?.total || 0; + + const handleEndReached = () => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }; + + return ( + + item.id} + renderItem={({ item }) => ( + router.push(`/user/${item.id}` as never)} + /> + )} + contentContainerClassName="p-4 pt-3" + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + refreshControl={ + + } + ListHeaderComponent={ + + Users + + {total} {total === 1 ? 'user' : 'users'} + + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={ + + + 0 + + No Users + + Users will appear here after syncing with your media server + + + } + /> + + ); +} diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx new file mode 100644 index 0000000..448b5e3 --- /dev/null +++ b/apps/mobile/app/+not-found.tsx @@ -0,0 +1,71 @@ +/** + * 404 Not Found screen + */ +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { colors, spacing, borderRadius, typography } from '@/lib/theme'; + +export default function NotFoundScreen() { + const router = useRouter(); + + return ( + <> + + + + 404 + Page Not Found + + The page you're looking for doesn't exist or has been moved. + + router.replace('/')}> + Go Home + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.dark, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + errorCode: { + fontSize: 72, + fontWeight: 'bold', + color: colors.cyan.core, + marginBottom: spacing.md, + }, + title: { + fontSize: typography.fontSize['2xl'], + fontWeight: 'bold', + color: colors.text.primary.dark, + marginBottom: spacing.sm, + }, + subtitle: { + fontSize: typography.fontSize.base, + color: colors.text.secondary.dark, + textAlign: 'center', + marginBottom: spacing.xl, + }, + button: { + backgroundColor: colors.cyan.core, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.md, + }, + buttonText: { + fontSize: typography.fontSize.base, + fontWeight: '600', + color: colors.blue.core, + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000..4a5888f --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,128 @@ +/** + * Root layout - handles auth state and navigation + */ +import { Buffer } from 'buffer'; +global.Buffer = Buffer; + +import '../global.css'; +import { useEffect, useState } from 'react'; +import { Stack, useRouter, useSegments } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { View, ActivityIndicator, StyleSheet } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { QueryProvider } from '@/providers/QueryProvider'; +import { SocketProvider } from '@/providers/SocketProvider'; +import { MediaServerProvider } from '@/providers/MediaServerProvider'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { useAuthStore } from '@/lib/authStore'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; +import { colors } from '@/lib/theme'; + +function RootLayoutNav() { + const { isAuthenticated, isLoading, initialize } = useAuthStore(); + const segments = useSegments(); + const router = useRouter(); + const [hasInitialized, setHasInitialized] = useState(false); + + // Initialize push notifications (only when authenticated) + usePushNotifications(); + + // Initialize auth state on mount + useEffect(() => { + void initialize().finally(() => setHasInitialized(true)); + }, [initialize]); + + // Handle navigation based on auth state + // Note: We allow authenticated users to access (auth)/pair for adding servers + // Wait for initialization to complete before redirecting (prevents hot reload issues) + useEffect(() => { + if (isLoading || !hasInitialized) return; + + const inAuthGroup = segments[0] === '(auth)'; + + if (!isAuthenticated && !inAuthGroup) { + // Not authenticated and not on auth screen - redirect to pair + router.replace('/(auth)/pair'); + } + // Don't redirect away from pair if authenticated - user might be adding a server + }, [isAuthenticated, isLoading, hasInitialized, segments, router]); + + // Show loading screen while initializing + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + + + + + + + + + + + ); +} + +export default function RootLayout() { + return ( + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.dark, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.dark, + }, +}); diff --git a/apps/mobile/app/session/[id].tsx b/apps/mobile/app/session/[id].tsx new file mode 100644 index 0000000..09f2d87 --- /dev/null +++ b/apps/mobile/app/session/[id].tsx @@ -0,0 +1,524 @@ +/** + * Session detail screen + * Shows comprehensive information about a specific session/stream + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { View, Text, ScrollView, Pressable, ActivityIndicator, Image, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import { useState, useEffect } from 'react'; +import { + Play, + Pause, + Square, + User, + Server, + MapPin, + Smartphone, + Clock, + Gauge, + Tv, + Film, + Music, + Zap, + Globe, + Wifi, + X, +} from 'lucide-react-native'; +import { api, getServerUrl } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { colors } from '@/lib/theme'; +import { Badge } from '@/components/ui/badge'; +import type { SessionWithDetails, SessionState, MediaType } from '@tracearr/shared'; + +// Safe date parsing helper - handles string dates from API +function safeParseDate(date: Date | string | null | undefined): Date | null { + if (!date) return null; + const parsed = new Date(date); + return isNaN(parsed.getTime()) ? null : parsed; +} + +// Safe format date helper +function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string { + const parsed = safeParseDate(date); + if (!parsed) return 'Unknown'; + return format(parsed, formatStr); +} + +// Get state icon, color, and badge variant +function getStateInfo(state: SessionState, watched?: boolean): { + icon: typeof Play; + color: string; + label: string; + variant: 'success' | 'warning' | 'secondary'; +} { + // Show "Watched" for completed sessions where user watched 80%+ + if (watched && state === 'stopped') { + return { icon: Play, color: colors.success, label: 'Watched', variant: 'success' }; + } + switch (state) { + case 'playing': + return { icon: Play, color: colors.success, label: 'Playing', variant: 'success' }; + case 'paused': + return { icon: Pause, color: colors.warning, label: 'Paused', variant: 'warning' }; + case 'stopped': + return { icon: Square, color: colors.text.secondary.dark, label: 'Stopped', variant: 'secondary' }; + default: + return { icon: Square, color: colors.text.secondary.dark, label: 'Unknown', variant: 'secondary' }; + } +} + +// Get media type icon +function getMediaIcon(mediaType: MediaType): typeof Film { + switch (mediaType) { + case 'movie': + return Film; + case 'episode': + return Tv; + case 'track': + return Music; + default: + return Film; + } +} + +// Format duration +function formatDuration(ms: number | null): string { + if (ms === null) return '-'; + const seconds = Math.floor(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${secs}s`; + } + return `${secs}s`; +} + +// Format bitrate +function formatBitrate(bitrate: number | null): string { + if (bitrate === null) return '-'; + if (bitrate >= 1000) { + return `${(bitrate / 1000).toFixed(1)} Mbps`; + } + return `${bitrate} Kbps`; +} + +// Info card component +function InfoCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +// Info row component +function InfoRow({ + icon: Icon, + label, + value, + valueColor, +}: { + icon: typeof Play; + label: string; + value: string; + valueColor?: string; +}) { + return ( + + + {label} + + {value} + + + ); +} + +// Progress bar component +function ProgressBar({ + progress, + total, +}: { + progress: number | null; + total: number | null; +}) { + if (progress === null || total === null || total === 0) { + return null; + } + + const percentage = Math.min((progress / total) * 100, 100); + + return ( + + + {formatDuration(progress)} + {formatDuration(total)} + + + + + + {percentage.toFixed(1)}% watched + + + ); +} + +export default function SessionDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { selectedServerId } = useMediaServer(); + const [serverUrl, setServerUrl] = useState(null); + + // Load server URL for image paths + useEffect(() => { + void getServerUrl().then(setServerUrl); + }, []); + + // Terminate session mutation + const terminateMutation = useMutation({ + mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) => + api.sessions.terminate(sessionId, reason), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] }); + Alert.alert('Stream Terminated', 'The playback session has been stopped.'); + router.back(); + }, + onError: (error: Error) => { + Alert.alert('Failed to Terminate', error.message); + }, + }); + + // Handle terminate button press + const handleTerminate = () => { + Alert.prompt( + 'Terminate Stream', + 'Enter an optional message to show the user (leave empty to skip):', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Terminate', + style: 'destructive', + onPress: (reason: string | undefined) => { + terminateMutation.mutate({ sessionId: id, reason: reason?.trim() || undefined }); + }, + }, + ], + 'plain-text', + '', + 'default' + ); + }; + + const { + data: session, + isLoading, + error, + } = useQuery({ + queryKey: ['session', id, selectedServerId], + queryFn: async () => { + console.log('[SessionDetail] Fetching session:', id); + try { + const result = await api.sessions.get(id); + console.log('[SessionDetail] Received session data:', JSON.stringify(result, null, 2)); + return result; + } catch (err) { + console.error('[SessionDetail] API error:', err); + throw err; + } + }, + enabled: !!id, + }); + + // Debug logging + useEffect(() => { + console.log('[SessionDetail] State:', { id, isLoading, hasError: !!error, hasSession: !!session }); + if (error) { + console.error('[SessionDetail] Query error:', error); + } + if (session) { + console.log('[SessionDetail] Session fields:', { + id: session.id, + username: session.username, + mediaTitle: session.mediaTitle, + state: session.state, + }); + } + }, [id, isLoading, error, session]); + + if (isLoading) { + return ( + + + + ); + } + + if (error || !session) { + return ( + + + {error instanceof Error ? error.message : 'Failed to load session'} + + + ); + } + + const stateInfo = getStateInfo(session.state, session.watched); + const MediaIcon = getMediaIcon(session.mediaType); + + // Format media title with episode info + const getMediaTitle = (): string => { + if (session.mediaType === 'episode' && session.grandparentTitle) { + const episodeInfo = session.seasonNumber && session.episodeNumber + ? `S${session.seasonNumber}E${session.episodeNumber}` + : ''; + return `${session.grandparentTitle}${episodeInfo ? ` • ${episodeInfo}` : ''}`; + } + return session.mediaTitle; + }; + + const getSubtitle = (): string => { + if (session.mediaType === 'episode') { + return session.mediaTitle; // Episode title + } + if (session.year) { + return String(session.year); + } + return ''; + }; + + // Get location string + const getLocation = (): string => { + const parts = [session.geoCity, session.geoRegion, session.geoCountry].filter(Boolean); + return parts.join(', ') || 'Unknown'; + }; + + return ( + + + {/* Media Header */} + + {/* Terminate button - top right */} + + + + + + + + {/* Poster/Thumbnail */} + + {session.thumbPath && serverUrl ? ( + + ) : ( + + + + )} + + + {/* Media Info */} + + + + {stateInfo.label} + + + + + {getMediaTitle()} + + + {getSubtitle() ? ( + + {getSubtitle()} + + ) : null} + + + + + {session.mediaType} + + + + + + {/* Progress bar */} + + + + {/* User Card - Tappable */} + router.push(`/user/${session.serverUserId}` as never)} + className="bg-card rounded-xl p-4 mb-4 active:opacity-70" + > + User + + + {session.userThumb ? ( + + ) : ( + + + + )} + + + + {session.username} + + Tap to view profile + + + + + + {/* Server Info */} + + + + + + {session.serverName} + + + {session.serverType} + + + + + + {/* Timing Info */} + + + {session.stoppedAt && ( + + )} + + {(session.pausedDurationMs ?? 0) > 0 && ( + + )} + + + {/* Location Info */} + + + + {session.geoLat && session.geoLon && ( + + )} + + + {/* Device Info */} + + + + + {session.product && ( + + )} + + + {/* Quality Info */} + + + + {session.bitrate && ( + + )} + + + {/* Bottom padding */} + + + + ); +} diff --git a/apps/mobile/app/session/_layout.tsx b/apps/mobile/app/session/_layout.tsx new file mode 100644 index 0000000..d92f186 --- /dev/null +++ b/apps/mobile/app/session/_layout.tsx @@ -0,0 +1,43 @@ +/** + * Session detail stack navigator layout + * Provides navigation for session detail screens + */ +import { Stack, useRouter } from 'expo-router'; +import { Pressable } from 'react-native'; +import { ChevronLeft } from 'lucide-react-native'; +import { colors } from '@/lib/theme'; + +export default function SessionLayout() { + const router = useRouter(); + + return ( + ( + router.back()} hitSlop={8}> + + + ), + contentStyle: { + backgroundColor: colors.background.dark, + }, + }} + > + + + ); +} diff --git a/apps/mobile/app/settings/_layout.tsx b/apps/mobile/app/settings/_layout.tsx new file mode 100644 index 0000000..8783967 --- /dev/null +++ b/apps/mobile/app/settings/_layout.tsx @@ -0,0 +1,43 @@ +/** + * Settings stack navigator layout + * Provides navigation between settings sub-screens + */ +import { Stack, useRouter } from 'expo-router'; +import { Pressable } from 'react-native'; +import { ChevronLeft } from 'lucide-react-native'; +import { colors } from '@/lib/theme'; + +export default function SettingsLayout() { + const router = useRouter(); + + return ( + ( + router.back()} hitSlop={8}> + + + ), + contentStyle: { + backgroundColor: colors.background.dark, + }, + }} + > + + + ); +} diff --git a/apps/mobile/app/settings/notifications.tsx b/apps/mobile/app/settings/notifications.tsx new file mode 100644 index 0000000..a257c8b --- /dev/null +++ b/apps/mobile/app/settings/notifications.tsx @@ -0,0 +1,538 @@ +/** + * Notification Settings Screen + * Per-device push notification configuration + */ +import { View, ScrollView, Switch, Pressable, ActivityIndicator, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Bell, + ShieldAlert, + Play, + Square, + Monitor, + Smartphone, + AlertTriangle, + ServerCrash, + ServerCog, + Moon, + Flame, + type LucideIcon, +} from 'lucide-react-native'; +import { Text } from '@/components/ui/text'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { api } from '@/lib/api'; +import { useAuthStore } from '@/lib/authStore'; +import { colors } from '@/lib/theme'; +import type { NotificationPreferences } from '@tracearr/shared'; + +// Rule types for violation filtering +const RULE_TYPES = [ + { value: 'impossible_travel', label: 'Impossible Travel' }, + { value: 'simultaneous_locations', label: 'Simultaneous Locations' }, + { value: 'device_velocity', label: 'Device Velocity' }, + { value: 'concurrent_streams', label: 'Concurrent Streams' }, + { value: 'geo_restriction', label: 'Geo Restriction' }, +] as const; + +// Severity levels +const SEVERITY_LEVELS = [ + { value: 1, label: 'All (Low, Warning, High)' }, + { value: 2, label: 'Warning & High only' }, + { value: 3, label: 'High severity only' }, +] as const; + +function Divider() { + return ; +} + +function SettingsSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} + +function SettingRow({ + icon: Icon, + label, + description, + value, + onValueChange, + disabled, +}: { + icon?: LucideIcon; + label: string; + description?: string; + value: boolean; + onValueChange: (value: boolean) => void; + disabled?: boolean; +}) { + return ( + + + + {Icon && ( + + )} + {label} + + {description && ( + + {description} + + )} + + + + ); +} + +function SelectRow({ + label, + value, + options, + onChange, + disabled, +}: { + label: string; + value: number; + options: ReadonlyArray<{ value: number; label: string }>; + onChange: (value: number) => void; + disabled?: boolean; +}) { + const currentOption = options.find((o) => o.value === value); + + const handlePress = () => { + if (disabled) return; + + Alert.alert( + label, + undefined, + options.map((option) => ({ + text: option.label, + onPress: () => onChange(option.value), + style: option.value === value ? 'cancel' : 'default', + })) + ); + }; + + return ( + + + {label} + + + {currentOption?.label ?? 'Select...'} + + + ); +} + +function MultiSelectRow({ + selectedValues, + options, + onChange, + disabled, +}: { + selectedValues: string[]; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (values: string[]) => void; + disabled?: boolean; +}) { + const toggleValue = (value: string) => { + if (disabled) return; + if (selectedValues.includes(value)) { + onChange(selectedValues.filter((v) => v !== value)); + } else { + onChange([...selectedValues, value]); + } + }; + + const allSelected = selectedValues.length === 0; + + return ( + + + onChange([])} + disabled={disabled} + className={cn( + 'px-3 py-1.5 rounded-full border', + allSelected + ? 'bg-cyan-core border-cyan-core' + : 'border-border bg-card', + disabled && 'opacity-50' + )} + > + + All Types + + + {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleValue(option.value)} + disabled={disabled} + className={cn( + 'px-3 py-1.5 rounded-full border', + isSelected + ? 'bg-cyan-core border-cyan-core' + : 'border-border bg-card', + disabled && 'opacity-50' + )} + > + + {option.label} + + + ); + })} + + + ); +} + +function RateLimitStatus({ + remainingMinute, + remainingHour, + maxPerMinute, + maxPerHour, +}: { + remainingMinute?: number; + remainingHour?: number; + maxPerMinute: number; + maxPerHour: number; +}) { + return ( + + Current Rate Limit Status + + + Per Minute + + {remainingMinute ?? maxPerMinute} / {maxPerMinute} + + + + Per Hour + + {remainingHour ?? maxPerHour} / {maxPerHour} + + + + + ); +} + +export default function NotificationSettingsScreen() { + const queryClient = useQueryClient(); + const { activeServerId } = useAuthStore(); + + // Fetch current preferences (per-device, not per-server) + const { + data: preferences, + isLoading, + error, + } = useQuery({ + queryKey: ['notifications', 'preferences'], + queryFn: api.notifications.getPreferences, + enabled: !!activeServerId, // Still need auth + }); + + // Update mutation with optimistic updates + const updateMutation = useMutation({ + mutationFn: api.notifications.updatePreferences, + onMutate: async (newData) => { + await queryClient.cancelQueries({ queryKey: ['notifications', 'preferences'] }); + const previousData = queryClient.getQueryData([ + 'notifications', + 'preferences', + ]); + queryClient.setQueryData(['notifications', 'preferences'], (old: NotificationPreferences | undefined) => + old ? { ...old, ...newData } : old + ); + return { previousData }; + }, + onError: (_err, _newData, context) => { + if (context?.previousData) { + queryClient.setQueryData(['notifications', 'preferences'], context.previousData); + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] }); + }, + }); + + // Test notification mutation + const testMutation = useMutation({ + mutationFn: api.notifications.sendTest, + onSuccess: (result) => { + Alert.alert( + result.success ? 'Test Sent' : 'Test Failed', + result.message + ); + }, + onError: (error: Error) => { + Alert.alert('Error', error.message || 'Failed to send test notification'); + }, + }); + + const handleUpdate = ( + key: keyof Omit, + value: boolean | number | string[] + ) => { + updateMutation.mutate({ [key]: value }); + }; + + if (isLoading) { + return ( + + + + Loading preferences... + + + ); + } + + if (error || !preferences) { + return ( + + + + Unable to Load Preferences + + + {error instanceof Error ? error.message : 'An error occurred'} + + + + ); + } + + const pushEnabled = preferences.pushEnabled; + + return ( + + + {/* Master Toggle */} + + handleUpdate('pushEnabled', v)} + /> + + + {/* Event Toggles */} + + handleUpdate('onViolationDetected', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onStreamStarted', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onStreamStopped', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onConcurrentStreams', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onNewDevice', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onTrustScoreChanged', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onServerDown', v)} + disabled={!pushEnabled} + /> + + handleUpdate('onServerUp', v)} + disabled={!pushEnabled} + /> + + + {/* Violation Filters - Only show if violation notifications are enabled */} + {pushEnabled && preferences.onViolationDetected && ( + + handleUpdate('violationRuleTypes', values)} + /> + + handleUpdate('violationMinSeverity', value)} + /> + + )} + + {/* Quiet Hours */} + + handleUpdate('quietHoursEnabled', v)} + disabled={!pushEnabled} + /> + {pushEnabled && preferences.quietHoursEnabled && ( + <> + + + + + Start Time + {preferences.quietHoursStart ?? '23:00'} + + to + + End Time + {preferences.quietHoursEnd ?? '08:00'} + + + + Timezone: {preferences.quietHoursTimezone || 'UTC'} + + + + handleUpdate('quietHoursOverrideCritical', v)} + /> + + )} + + + {/* Rate Limiting */} + + + + + + Rate limits prevent notification spam. Current limits: {preferences.maxPerMinute}/min, {preferences.maxPerHour}/hour. + + + + + {/* Test Notification */} + + + + Verify that push notifications are working correctly + + + + + ); +} diff --git a/apps/mobile/app/user/[id].tsx b/apps/mobile/app/user/[id].tsx new file mode 100644 index 0000000..8a8dbf6 --- /dev/null +++ b/apps/mobile/app/user/[id].tsx @@ -0,0 +1,815 @@ +/** + * User Detail Screen + * Shows comprehensive user information with web feature parity + * Query keys include selectedServerId for proper cache isolation per media server + */ +import { View, ScrollView, RefreshControl, Pressable, ActivityIndicator, Image } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { formatDistanceToNow, format } from 'date-fns'; +import { + Crown, + Play, + Clock, + AlertTriangle, + Globe, + MapPin, + Smartphone, + Monitor, + Tv, + ChevronRight, + Users, + Zap, + Check, + Film, + Music, + XCircle, + User, + Bot, + type LucideIcon, +} from 'lucide-react-native'; +import { useEffect, useState } from 'react'; +import { api, getServerUrl } from '@/lib/api'; +import { useMediaServer } from '@/providers/MediaServerProvider'; +import { Text } from '@/components/ui/text'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { UserAvatar } from '@/components/ui/user-avatar'; +import { cn } from '@/lib/utils'; +import { colors } from '@/lib/theme'; +import type { + Session, + ViolationWithDetails, + UserLocation, + UserDevice, + RuleType, + TerminationLogWithDetails, +} from '@tracearr/shared'; + +const PAGE_SIZE = 10; + +// Safe date parsing helper - handles string dates from API +function safeParseDate(date: Date | string | null | undefined): Date | null { + if (!date) return null; + const parsed = new Date(date); + return isNaN(parsed.getTime()) ? null : parsed; +} + +// Safe format distance helper +function safeFormatDistanceToNow(date: Date | string | null | undefined): string { + const parsed = safeParseDate(date); + if (!parsed) return 'Unknown'; + return formatDistanceToNow(parsed, { addSuffix: true }); +} + +// Safe format date helper +function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string { + const parsed = safeParseDate(date); + if (!parsed) return 'Unknown'; + return format(parsed, formatStr); +} + +// Rule type icons mapping +const ruleIcons: Record = { + impossible_travel: MapPin, + simultaneous_locations: Users, + device_velocity: Zap, + concurrent_streams: Monitor, + geo_restriction: Globe, +}; + +// Rule type display names +const ruleLabels: Record = { + impossible_travel: 'Impossible Travel', + simultaneous_locations: 'Simultaneous Locations', + device_velocity: 'Device Velocity', + concurrent_streams: 'Concurrent Streams', + geo_restriction: 'Geo Restriction', +}; + +function TrustScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) { + const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success'; + const label = score < 50 ? 'Low' : score < 75 ? 'Medium' : 'High'; + + return ( + + + + {score} + + + {showLabel && ( + {label} Trust + )} + + ); +} + +function StatCard({ icon: Icon, label, value, subValue }: { + icon: LucideIcon; + label: string; + value: string | number; + subValue?: string; +}) { + return ( + + + + {label} + + {value} + {subValue && {subValue}} + + ); +} + +function SeverityBadge({ severity }: { severity: string }) { + const variant = + severity === 'critical' || severity === 'high' + ? 'destructive' + : severity === 'warning' + ? 'warning' + : 'default'; + + return ( + + {severity} + + ); +} + +function formatDuration(ms: number | null): string { + if (!ms) return '-'; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function LocationCard({ location }: { location: UserLocation }) { + const locationText = [location.city, location.region, location.country] + .filter(Boolean) + .join(', ') || 'Unknown Location'; + + return ( + + + + + + {locationText} + + {location.sessionCount} {location.sessionCount === 1 ? 'session' : 'sessions'} + {' • '} + {safeFormatDistanceToNow(location.lastSeenAt)} + + + + ); +} + +function DeviceCard({ device }: { device: UserDevice }) { + const deviceName = device.playerName || device.device || device.product || 'Unknown Device'; + const platform = device.platform || 'Unknown Platform'; + + return ( + + + + + + {deviceName} + + {platform} • {device.sessionCount} {device.sessionCount === 1 ? 'session' : 'sessions'} + + + Last seen {safeFormatDistanceToNow(device.lastSeenAt)} + + + + ); +} + +function getMediaIcon(mediaType: string): typeof Film { + switch (mediaType) { + case 'movie': + return Film; + case 'episode': + return Tv; + case 'track': + return Music; + default: + return Film; + } +} + +function SessionCard({ session, onPress, serverUrl }: { session: Session; onPress?: () => void; serverUrl: string | null }) { + const locationText = [session.geoCity, session.geoCountry].filter(Boolean).join(', '); + const MediaIcon = getMediaIcon(session.mediaType); + + // Build poster URL - need serverId and thumbPath + const hasPoster = serverUrl && session.thumbPath && session.serverId; + const posterUrl = hasPoster + ? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath!)}&width=80&height=120` + : null; + + // Determine display state - show "Watched" for completed sessions that reached 80%+ + const getDisplayState = () => { + if (session.watched) return { label: 'Watched', variant: 'success' as const }; + if (session.state === 'playing') return { label: 'Playing', variant: 'success' as const }; + if (session.state === 'paused') return { label: 'Paused', variant: 'warning' as const }; + if (session.state === 'stopped') return { label: 'Stopped', variant: 'secondary' as const }; + return { label: session.state || 'Unknown', variant: 'secondary' as const }; + }; + const displayState = getDisplayState(); + + return ( + + + {/* Poster */} + + {posterUrl ? ( + + ) : ( + + + + )} + + + {/* Content */} + + + + + {session.mediaTitle} + + {session.mediaType} + + + {displayState.label} + + + + + + {formatDuration(session.durationMs)} + + + + {session.platform || 'Unknown'} + + {locationText && ( + + + {locationText} + + )} + + + + + ); +} + +function ViolationCard({ + violation, + onAcknowledge, +}: { + violation: ViolationWithDetails; + onAcknowledge: () => void; +}) { + const ruleType = violation.rule?.type as RuleType | undefined; + const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule'; + const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle; + const timeAgo = safeFormatDistanceToNow(violation.createdAt); + + return ( + + + + + + + + {ruleName} + {timeAgo} + + + + + {!violation.acknowledgedAt ? ( + + + Acknowledge + + ) : ( + + + Acknowledged + + )} + + ); +} + +function TerminationCard({ termination }: { termination: TerminationLogWithDetails }) { + const timeAgo = safeFormatDistanceToNow(termination.createdAt); + const isManual = termination.trigger === 'manual'; + + return ( + + + + + {isManual ? ( + + ) : ( + + )} + + + + {termination.mediaTitle ?? 'Unknown Media'} + + + {termination.mediaType ?? 'unknown'} • {timeAgo} + + + + + {isManual ? 'Manual' : 'Rule'} + + + + + {isManual + ? `By @${termination.triggeredByUsername ?? 'Unknown'}` + : termination.ruleName ?? 'Unknown rule'} + + {termination.reason && ( + + Reason: {termination.reason} + + )} + + {termination.success ? ( + <> + + Success + + ) : ( + <> + + Failed + + )} + + + + ); +} + +export default function UserDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const navigation = useNavigation(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { selectedServerId } = useMediaServer(); + const [serverUrl, setServerUrl] = useState(null); + + // Load server URL for image proxy + useEffect(() => { + void getServerUrl().then(setServerUrl); + }, []); + + // Fetch user detail - query keys include selectedServerId for cache isolation + const { + data: user, + isLoading: userLoading, + refetch: refetchUser, + isRefetching: userRefetching, + } = useQuery({ + queryKey: ['user', id, selectedServerId], + queryFn: () => api.users.get(id), + enabled: !!id, + }); + + // Update header title with username + useEffect(() => { + if (user?.username) { + navigation.setOptions({ title: user.username }); + } + }, [user?.username, navigation]); + + // Fetch user sessions + const { + data: sessionsData, + isLoading: sessionsLoading, + fetchNextPage: fetchMoreSessions, + hasNextPage: hasMoreSessions, + isFetchingNextPage: fetchingMoreSessions, + } = useInfiniteQuery({ + queryKey: ['user', id, 'sessions', selectedServerId], + queryFn: ({ pageParam = 1 }) => api.users.sessions(id, { page: pageParam, pageSize: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; totalPages: number }) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: !!id, + }); + + // Fetch user violations + const { + data: violationsData, + isLoading: violationsLoading, + fetchNextPage: fetchMoreViolations, + hasNextPage: hasMoreViolations, + isFetchingNextPage: fetchingMoreViolations, + } = useInfiniteQuery({ + queryKey: ['violations', { userId: id }, selectedServerId], + queryFn: ({ pageParam = 1 }) => api.violations.list({ userId: id, page: pageParam, pageSize: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; totalPages: number }) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: !!id, + }); + + // Fetch user locations + const { data: locations, isLoading: locationsLoading } = useQuery({ + queryKey: ['user', id, 'locations', selectedServerId], + queryFn: () => api.users.locations(id), + enabled: !!id, + }); + + // Fetch user devices + const { data: devices, isLoading: devicesLoading } = useQuery({ + queryKey: ['user', id, 'devices', selectedServerId], + queryFn: () => api.users.devices(id), + enabled: !!id, + }); + + // Fetch user terminations + const { + data: terminationsData, + isLoading: terminationsLoading, + fetchNextPage: fetchMoreTerminations, + hasNextPage: hasMoreTerminations, + isFetchingNextPage: fetchingMoreTerminations, + } = useInfiniteQuery({ + queryKey: ['user', id, 'terminations', selectedServerId], + queryFn: ({ pageParam = 1 }) => + api.users.terminations(id, { page: pageParam, pageSize: PAGE_SIZE }), + initialPageParam: 1, + getNextPageParam: (lastPage: { page: number; totalPages: number }) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: !!id, + }); + + // Acknowledge mutation + const acknowledgeMutation = useMutation({ + mutationFn: api.violations.acknowledge, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] }); + }, + }); + + const sessions = sessionsData?.pages.flatMap((page) => page.data) || []; + const violations = violationsData?.pages.flatMap((page) => page.data) || []; + const terminations = terminationsData?.pages.flatMap((page) => page.data) || []; + const totalSessions = sessionsData?.pages[0]?.total || 0; + const totalViolations = violationsData?.pages[0]?.total || 0; + const totalTerminations = terminationsData?.pages[0]?.total || 0; + + const handleRefresh = () => { + void refetchUser(); + void queryClient.invalidateQueries({ queryKey: ['user', id, 'sessions', selectedServerId] }); + void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] }); + void queryClient.invalidateQueries({ queryKey: ['user', id, 'locations', selectedServerId] }); + void queryClient.invalidateQueries({ queryKey: ['user', id, 'devices', selectedServerId] }); + void queryClient.invalidateQueries({ queryKey: ['user', id, 'terminations', selectedServerId] }); + }; + + const handleSessionPress = (session: Session) => { + router.push(`/session/${session.id}` as never); + }; + + if (userLoading) { + return ( + + + + + + ); + } + + if (!user) { + return ( + + + User Not Found + This user may have been removed. + + + ); + } + + return ( + + + } + > + {/* User Info Card */} + + + + + + {user.username} + {user.role === 'owner' && ( + + )} + + {user.email && ( + {user.email} + )} + + + + + + {/* Stats Grid */} + + + + + + + + + + {/* Locations */} + + + + Locations + + {locations?.length || 0} {locations?.length === 1 ? 'location' : 'locations'} + + + + + {locationsLoading ? ( + + ) : locations && locations.length > 0 ? ( + locations.slice(0, 5).map((location, index) => ( + + )) + ) : ( + No locations recorded + )} + {locations && locations.length > 5 && ( + + + +{locations.length - 5} more locations + + + )} + + + + {/* Devices */} + + + + Devices + + {devices?.length || 0} {devices?.length === 1 ? 'device' : 'devices'} + + + + + {devicesLoading ? ( + + ) : devices && devices.length > 0 ? ( + devices.slice(0, 5).map((device, index) => ( + + )) + ) : ( + No devices recorded + )} + {devices && devices.length > 5 && ( + + + +{devices.length - 5} more devices + + + )} + + + + {/* Recent Sessions */} + + + + Recent Sessions + {totalSessions} total + + + + {sessionsLoading ? ( + + ) : sessions.length > 0 ? ( + <> + {sessions.map((session) => ( + handleSessionPress(session)} + /> + ))} + {hasMoreSessions && ( + void fetchMoreSessions()} + disabled={fetchingMoreSessions} + > + {fetchingMoreSessions ? ( + + ) : ( + + Load More + + + )} + + )} + + ) : ( + No sessions found + )} + + + + {/* Violations */} + + + + Violations + {totalViolations} total + + + + {violationsLoading ? ( + + ) : violations.length > 0 ? ( + <> + {violations.map((violation) => ( + acknowledgeMutation.mutate(violation.id)} + /> + ))} + {hasMoreViolations && ( + void fetchMoreViolations()} + disabled={fetchingMoreViolations} + > + {fetchingMoreViolations ? ( + + ) : ( + + Load More + + + )} + + )} + + ) : ( + + + + + No violations + + )} + + + + {/* Termination History */} + + + + + + Termination History + + {totalTerminations} total + + + + {terminationsLoading ? ( + + ) : terminations.length > 0 ? ( + <> + {terminations.map((termination) => ( + + ))} + {hasMoreTerminations && ( + void fetchMoreTerminations()} + disabled={fetchingMoreTerminations} + > + {fetchingMoreTerminations ? ( + + ) : ( + + Load More + + + )} + + )} + + ) : ( + + No stream terminations + + )} + + + + + ); +} diff --git a/apps/mobile/app/user/_layout.tsx b/apps/mobile/app/user/_layout.tsx new file mode 100644 index 0000000..f43452e --- /dev/null +++ b/apps/mobile/app/user/_layout.tsx @@ -0,0 +1,43 @@ +/** + * User detail stack navigator layout + * Provides navigation for user detail screens + */ +import { Stack, useRouter } from 'expo-router'; +import { Pressable } from 'react-native'; +import { ChevronLeft } from 'lucide-react-native'; +import { colors } from '@/lib/theme'; + +export default function UserLayout() { + const router = useRouter(); + + return ( + ( + router.back()} hitSlop={8}> + + + ), + contentStyle: { + backgroundColor: colors.background.dark, + }, + }} + > + + + ); +} diff --git a/apps/mobile/assets/adaptive-icon-transparent.png b/apps/mobile/assets/adaptive-icon-transparent.png new file mode 100644 index 0000000..1e9b782 Binary files /dev/null and b/apps/mobile/assets/adaptive-icon-transparent.png differ diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000..943d8e5 Binary files /dev/null and b/apps/mobile/assets/adaptive-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000..de206fc Binary files /dev/null and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/fonts/Inter_500Medium.ttf b/apps/mobile/assets/fonts/Inter_500Medium.ttf new file mode 100644 index 0000000..e3e4daa Binary files /dev/null and b/apps/mobile/assets/fonts/Inter_500Medium.ttf differ diff --git a/apps/mobile/assets/icon-transparent.png b/apps/mobile/assets/icon-transparent.png new file mode 100644 index 0000000..cd743ac Binary files /dev/null and b/apps/mobile/assets/icon-transparent.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000..37028ec Binary files /dev/null and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/logo-transparent.png b/apps/mobile/assets/logo-transparent.png new file mode 100644 index 0000000..cd743ac Binary files /dev/null and b/apps/mobile/assets/logo-transparent.png differ diff --git a/apps/mobile/assets/notification-icon.png b/apps/mobile/assets/notification-icon.png new file mode 100644 index 0000000..126153d Binary files /dev/null and b/apps/mobile/assets/notification-icon.png differ diff --git a/apps/mobile/assets/splash-icon-transparent.png b/apps/mobile/assets/splash-icon-transparent.png new file mode 100644 index 0000000..cd743ac Binary files /dev/null and b/apps/mobile/assets/splash-icon-transparent.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000..37028ec Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 0000000..08ea68b --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,19 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module-resolver', + { + root: ['.'], + alias: { + '@': './src', + }, + extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'], + }, + ], + 'react-native-reanimated/plugin', + ], + }; +}; diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..4ed9aa4 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,76 @@ +{ + "cli": { + "version": ">= 12.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "simulator": false + }, + "android": { + "buildType": "apk" + } + }, + "preview": { + "ios": { + "simulator": false, + "autoIncrement": true + }, + "android": { + "buildType": "app-bundle" + }, + "env": { + "EXPO_PUBLIC_API_URL": "https://tracearr.example.com" + }, + "channel": "preview" + }, + "preview-apk": { + "distribution": "internal", + "android": { + "buildType": "apk" + }, + "channel": "preview" + }, + "production": { + "ios": { + "resourceClass": "m-medium" + }, + "android": { + "buildType": "app-bundle" + }, + "env": { + "EXPO_PUBLIC_API_URL": "https://tracearr.example.com" + }, + "channel": "production", + "autoIncrement": true + } + }, + "submit": { + "preview": { + "ios": { + "appleId": "connor.gallopo@me.com", + "ascAppId": "6755941553", + "appleTeamId": "6DA3FJF5G5" + }, + "android": { + "serviceAccountKeyPath": "./credentials/google-service-account.json", + "track": "internal", + "releaseStatus": "draft" + } + }, + "production": { + "ios": { + "appleId": "connor.gallopo@me.com", + "ascAppId": "6755941553", + "appleTeamId": "6DA3FJF5G5" + }, + "android": { + "serviceAccountKeyPath": "./credentials/google-service-account.json", + "track": "production" + } + } + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css new file mode 100644 index 0000000..55c2446 --- /dev/null +++ b/apps/mobile/global.css @@ -0,0 +1,62 @@ +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css"; +@import "nativewind/theme"; + +/* Tracearr brand colors - matching web dark mode exactly */ +@theme { + /* Brand colors */ + --color-cyan-core: #18D1E7; + --color-cyan-deep: #0EAFC8; + --color-cyan-dark: #0A7C96; + --color-blue-core: #0B1A2E; + --color-blue-steel: #162840; + --color-blue-soft: #1E3A5C; + + /* Background colors - matching web dark mode exactly */ + --color-background: #050A12; + --color-card: #0B1A2E; + --color-card-foreground: #FFFFFF; + --color-surface: #0F2338; + --color-popover: #162840; + --color-popover-foreground: #FFFFFF; + + /* Text colors */ + --color-foreground: #FFFFFF; + --color-muted: #162840; + --color-muted-foreground: #94A3B8; + + /* Primary (cyan) */ + --color-primary: #18D1E7; + --color-primary-foreground: #0B1A2E; + + /* Secondary */ + --color-secondary: #162840; + --color-secondary-foreground: #FFFFFF; + + /* Accent (same as primary) */ + --color-accent: #18D1E7; + --color-accent-foreground: #0B1A2E; + + /* Form inputs */ + --color-input: #162840; + --color-ring: #18D1E7; + --color-border: #162840; + + /* Semantic colors */ + --color-destructive: #EF4444; + --color-destructive-foreground: #FFFFFF; + --color-success: #22C55E; + --color-warning: #F59E0B; + --color-danger: #EF4444; + + /* Icon colors */ + --color-icon: #8CA3B8; + --color-icon-active: #18D1E7; + --color-icon-danger: #FF4C4C; + + /* Border radius */ + --radius-lg: 0.5rem; + --radius-md: calc(var(--radius-lg) - 2px); + --radius-sm: calc(var(--radius-lg) - 4px); +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000..cff7aa0 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,38 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withNativewind } = require('nativewind/metro'); +const path = require('path'); + +// Find the project and workspace directories +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +const config = getDefaultConfig(projectRoot); + +// 1. Watch all files within the monorepo (include default watchFolders) +config.watchFolders = [...(config.watchFolders || []), monorepoRoot]; + +// 2. Let Metro know where to resolve packages from +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), +]; + +// 3. Enable symlink support for pnpm +config.resolver.unstable_enableSymlinks = true; + +// 4. Handle .js imports that should resolve to .ts files (NodeNext compatibility) +// TypeScript with moduleResolution: NodeNext requires .js extensions in imports +// even for .ts source files. Metro needs help resolving these correctly. +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName.startsWith('.') && moduleName.endsWith('.js')) { + const tsModuleName = moduleName.replace(/\.js$/, '.ts'); + try { + return context.resolveRequest(context, tsModuleName, platform); + } catch { + // Fall through to default resolution if .ts doesn't exist + } + } + return context.resolveRequest(context, moduleName, platform); +}; + +module.exports = withNativewind(config, { input: './global.css' }); diff --git a/apps/mobile/nativewind-env.d.ts b/apps/mobile/nativewind-env.d.ts new file mode 100644 index 0000000..6633b2c --- /dev/null +++ b/apps/mobile/nativewind-env.d.ts @@ -0,0 +1,4 @@ +/// +// NOTE: This file should not be edited and should be committed with your source code. +// It is generated by react-native-css. If you need to move or disable this file, +// please see the documentation. diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..29b8900 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,86 @@ +{ + "name": "@tracearr/mobile", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "dev": "expo start", + "lint": "eslint . --ext .ts,.tsx --ignore-pattern '*.config.*' --ignore-pattern '.expo/'", + "typecheck": "tsc --noEmit", + "build:dev": "eas build --profile development --platform all", + "build:dev:ios": "eas build --profile development --platform ios", + "build:dev:android": "eas build --profile development --platform android", + "build:preview": "eas build --profile preview --platform all", + "build:preview:ios": "eas build --profile preview --platform ios", + "build:preview:android": "eas build --profile preview --platform android", + "build:prod": "eas build --profile production --platform all", + "build:prod:ios": "eas build --profile production --platform ios", + "build:prod:android": "eas build --profile production --platform android", + "submit:ios": "eas submit --platform ios", + "submit:android": "eas submit --platform android", + "update": "eas update" + }, + "dependencies": { + "@expo-google-fonts/inter": "^0.4.2", + "@expo/metro-runtime": "~6.1.2", + "@expo/vector-icons": "^15.0.3", + "@react-navigation/native": "7.1.22", + "@shopify/react-native-skia": "^2.2.12", + "@tanstack/react-query": "5.60.6", + "@tracearr/shared": "workspace:*", + "axios": "^1.12.0", + "buffer": "^6.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "expo": "54.0.25", + "expo-camera": "17.0.9", + "expo-constants": "18.0.10", + "expo-dev-client": "~6.0.18", + "expo-device": "8.0.9", + "expo-font": "^14.0.9", + "expo-image": "3.0.10", + "expo-linking": "8.0.9", + "expo-maps": "^0.12.8", + "expo-notifications": "0.32.13", + "expo-router": "6.0.15", + "expo-secure-store": "15.0.7", + "expo-splash-screen": "31.0.11", + "expo-status-bar": "3.0.8", + "expo-system-ui": "6.0.8", + "expo-task-manager": "~14.0.8", + "expo-updates": "~29.0.13", + "expo-web-browser": "15.0.9", + "lucide-react-native": "^0.555.0", + "nativewind": "5.0.0-preview.2", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-css": "^3.0.1", + "react-native-gesture-handler": "2.28.0", + "react-native-quick-crypto": "^1.0.0", + "react-native-reanimated": "4.1.5", + "react-native-safe-area-context": "5.6.2", + "react-native-screens": "4.16.0", + "react-native-svg": "15.15.0", + "react-native-worklets": "0.5.1", + "socket.io-client": "4.8.1", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^4.1.17", + "victory-native": "41.20.2", + "zustand": "5.0.2" + }, + "devDependencies": { + "@babel/core": "7.26.0", + "@tailwindcss/postcss": "^4.1.17", + "@types/react": "19.1.17", + "babel-plugin-module-resolver": "5.0.2", + "babel-preset-expo": "^54.0.7", + "postcss": "^8.5.6", + "tailwindcss-animate": "^1.0.7", + "typescript": "5.9.2" + }, + "private": true +} diff --git a/apps/mobile/plugins/withGradleProperties.js b/apps/mobile/plugins/withGradleProperties.js new file mode 100644 index 0000000..9697b0e --- /dev/null +++ b/apps/mobile/plugins/withGradleProperties.js @@ -0,0 +1,26 @@ +const { withGradleProperties } = require('expo/config-plugins'); + +/** + * Config plugin to customize Android gradle.properties + * Used to set JVM memory args for builds with many native dependencies + */ +module.exports = function withCustomGradleProperties(config, props) { + return withGradleProperties(config, (config) => { + for (const [key, value] of Object.entries(props)) { + const existingIndex = config.modResults.findIndex( + (p) => p.type === 'property' && p.key === key + ); + + if (existingIndex !== -1) { + config.modResults[existingIndex].value = value; + } else { + config.modResults.push({ + type: 'property', + key, + value, + }); + } + } + return config; + }); +}; diff --git a/apps/mobile/postcss.config.mjs b/apps/mobile/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/apps/mobile/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/mobile/src/components/ErrorBoundary.tsx b/apps/mobile/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..5f3e6d2 --- /dev/null +++ b/apps/mobile/src/components/ErrorBoundary.tsx @@ -0,0 +1,163 @@ +/** + * Error Boundary component for catching and displaying React errors + */ +import React, { Component, type ReactNode } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; +import { AlertTriangle, RefreshCw } from 'lucide-react-native'; +import { colors } from '@/lib/theme'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + this.setState({ errorInfo }); + + // Log error for debugging + console.error('ErrorBoundary caught an error:', error, errorInfo); + + // Call optional error handler + this.props.onError?.(error, errorInfo); + } + + handleReset = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render(): ReactNode { + if (this.state.hasError) { + // Custom fallback provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI + return ( + + + + Something went wrong + + An unexpected error occurred. Please try again. + + + {__DEV__ && this.state.error && ( + + Error Details: + {this.state.error.message} + {this.state.errorInfo?.componentStack && ( + <> + Component Stack: + + {this.state.errorInfo.componentStack} + + + )} + + )} + + + + Try Again + + + + ); + } + + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.dark, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + content: { + alignItems: 'center', + maxWidth: 320, + }, + title: { + fontSize: 20, + fontWeight: '600', + color: colors.text.primary.dark, + marginTop: 16, + marginBottom: 8, + }, + message: { + fontSize: 14, + color: colors.text.secondary.dark, + textAlign: 'center', + lineHeight: 20, + }, + errorContainer: { + maxHeight: 200, + marginTop: 16, + padding: 12, + backgroundColor: colors.card.dark, + borderRadius: 8, + width: '100%', + }, + errorTitle: { + fontSize: 12, + fontWeight: '600', + color: colors.error, + marginBottom: 4, + marginTop: 8, + }, + errorText: { + fontSize: 12, + color: colors.text.secondary.dark, + fontFamily: 'monospace', + }, + stackText: { + fontSize: 10, + color: colors.text.muted.dark, + fontFamily: 'monospace', + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: colors.cyan.core, + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 8, + marginTop: 24, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: colors.text.primary.dark, + }, +}); diff --git a/apps/mobile/src/components/ServerSelector.tsx b/apps/mobile/src/components/ServerSelector.tsx new file mode 100644 index 0000000..63971ca --- /dev/null +++ b/apps/mobile/src/components/ServerSelector.tsx @@ -0,0 +1,126 @@ +/** + * Server selector component for header + * Tappable button that shows current server, opens modal to switch + */ +import { useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + Pressable, + ActivityIndicator, +} from 'react-native'; +import { Server, ChevronDown, Check } from 'lucide-react-native'; +import { useMediaServer } from '../providers/MediaServerProvider'; +import { colors } from '../lib/theme'; + +export function ServerSelector() { + const { servers, selectedServer, selectedServerId, selectServer, isLoading } = useMediaServer(); + const [modalVisible, setModalVisible] = useState(false); + + // Don't show if loading or no servers + if (isLoading) { + return ( + + + + ); + } + + // Don't show selector if only one server + if (servers.length <= 1) { + if (servers.length === 1) { + return ( + + + + {servers[0]?.name} + + + ); + } + return null; + } + + const handleSelect = (serverId: string) => { + selectServer(serverId); + setModalVisible(false); + }; + + return ( + <> + setModalVisible(true)} + className="flex-row items-center px-3 py-2" + activeOpacity={0.7} + > + + + {selectedServer?.name ?? 'Select Server'} + + + + + setModalVisible(false)} + > + setModalVisible(false)} + > + e.stopPropagation()} + > + + Select Server + + + {servers.map((server) => ( + handleSelect(server.id)} + className="flex-row items-center justify-between px-4 py-3" + activeOpacity={0.7} + > + + + + + {server.name} + + + {server.type} + + + + {server.id === selectedServerId && ( + + )} + + ))} + + + + + + ); +} diff --git a/apps/mobile/src/components/charts/DayOfWeekChart.tsx b/apps/mobile/src/components/charts/DayOfWeekChart.tsx new file mode 100644 index 0000000..62af5bd --- /dev/null +++ b/apps/mobile/src/components/charts/DayOfWeekChart.tsx @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ +/** + * Bar chart showing plays by day of week with touch interaction + */ +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { CartesianChart, Bar, useChartPressState } from 'victory-native'; +import { Circle } from '@shopify/react-native-skia'; +import { useAnimatedReaction, runOnJS } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { colors, spacing, borderRadius, typography } from '../../lib/theme'; +import { useChartFont } from './useChartFont'; + +interface DayOfWeekChartProps { + data: { day: number; name: string; count: number }[]; + height?: number; +} + +const DAY_ABBREV = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function ToolTip({ x, y }: { x: SharedValue; y: SharedValue }) { + return ; +} + +export function DayOfWeekChart({ data, height = 180 }: DayOfWeekChartProps) { + const font = useChartFont(10); + const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } }); + + // React state to display values (synced from SharedValues) + const [displayValue, setDisplayValue] = useState<{ + day: number; + count: number; + } | null>(null); + + // Sync SharedValue changes to React state + const updateDisplayValue = useCallback( + (day: number, count: number) => { + setDisplayValue({ day: Math.round(day), count: Math.round(count) }); + }, + [] + ); + + const clearDisplayValue = useCallback(() => { + setDisplayValue(null); + }, []); + + // Watch for changes in chart press state + useAnimatedReaction( + () => ({ + active: isActive, + x: state.x.value.value, + y: state.y.count.value.value, + }), + (current, previous) => { + if (current.active) { + runOnJS(updateDisplayValue)(current.x, current.y); + } else if (previous?.active && !current.active) { + runOnJS(clearDisplayValue)(); + } + }, + [isActive] + ); + + // Transform data for victory-native + const chartData = data.map((d) => ({ + x: d.day, + count: d.count, + name: d.name, + })); + + if (chartData.length === 0) { + return ( + + No data available + + ); + } + + // Find the selected day name from React state + const selectedDay = displayValue + ? chartData.find((d) => d.x === displayValue.day) + : null; + + return ( + + {/* Active value display */} + + {displayValue && selectedDay ? ( + <> + {displayValue.count} plays + {selectedDay.name} + + ) : null} + + + DAY_ABBREV[Math.round(value)] || '', + formatYLabel: (value) => String(Math.round(value)), + }} + > + {({ points, chartBounds }) => ( + <> + + {isActive && ( + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.sm, + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + valueDisplay: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.xs, + marginBottom: spacing.xs, + minHeight: 18, + }, + valueText: { + color: colors.cyan.core, + fontSize: typography.fontSize.sm, + fontWeight: '600', + }, + dayText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.xs, + }, +}); diff --git a/apps/mobile/src/components/charts/HourOfDayChart.tsx b/apps/mobile/src/components/charts/HourOfDayChart.tsx new file mode 100644 index 0000000..08fb0ea --- /dev/null +++ b/apps/mobile/src/components/charts/HourOfDayChart.tsx @@ -0,0 +1,163 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ +/** + * Bar chart showing plays by hour of day with touch interaction + */ +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { CartesianChart, Bar, useChartPressState } from 'victory-native'; +import { Circle } from '@shopify/react-native-skia'; +import { useAnimatedReaction, runOnJS } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { colors, spacing, borderRadius, typography } from '../../lib/theme'; +import { useChartFont } from './useChartFont'; + +interface HourOfDayChartProps { + data: { hour: number; count: number }[]; + height?: number; +} + +function ToolTip({ x, y }: { x: SharedValue; y: SharedValue }) { + return ; +} + +function formatHour(hour: number): string { + if (hour === 0) return '12am'; + if (hour === 12) return '12pm'; + return hour < 12 ? `${hour}am` : `${hour - 12}pm`; +} + +export function HourOfDayChart({ data, height = 180 }: HourOfDayChartProps) { + const font = useChartFont(9); + const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } }); + + // React state to display values (synced from SharedValues) + const [displayValue, setDisplayValue] = useState<{ + hour: number; + count: number; + } | null>(null); + + // Sync SharedValue changes to React state + const updateDisplayValue = useCallback((hour: number, count: number) => { + setDisplayValue({ hour: Math.round(hour), count: Math.round(count) }); + }, []); + + const clearDisplayValue = useCallback(() => { + setDisplayValue(null); + }, []); + + // Watch for changes in chart press state + useAnimatedReaction( + () => ({ + active: isActive, + x: state.x.value.value, + y: state.y.count.value.value, + }), + (current, previous) => { + if (current.active) { + runOnJS(updateDisplayValue)(current.x, current.y); + } else if (previous?.active && !current.active) { + runOnJS(clearDisplayValue)(); + } + }, + [isActive] + ); + + // Transform data for victory-native + const chartData = data.map((d) => ({ + x: d.hour, + count: d.count, + })); + + if (chartData.length === 0) { + return ( + + No data available + + ); + } + + return ( + + {/* Active value display */} + + {displayValue ? ( + <> + {displayValue.count} plays + {formatHour(displayValue.hour)} + + ) : null} + + + { + const hour = Math.round(value); + // Only show labels for 0, 6, 12, 18 to avoid crowding + if (hour % 6 === 0) { + return formatHour(hour); + } + return ''; + }, + formatYLabel: (value) => String(Math.round(value)), + }} + > + {({ points, chartBounds }) => ( + <> + + {isActive && ( + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.sm, + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + valueDisplay: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.xs, + marginBottom: spacing.xs, + minHeight: 18, + }, + valueText: { + color: colors.purple, + fontSize: typography.fontSize.sm, + fontWeight: '600', + }, + hourText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.xs, + }, +}); diff --git a/apps/mobile/src/components/charts/PlatformChart.tsx b/apps/mobile/src/components/charts/PlatformChart.tsx new file mode 100644 index 0000000..2bc6b61 --- /dev/null +++ b/apps/mobile/src/components/charts/PlatformChart.tsx @@ -0,0 +1,130 @@ +/** + * Donut chart showing plays by platform (matches web implementation) + * Note: Touch interactions not yet supported on PolarChart (victory-native issue #252) + */ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Pie, PolarChart } from 'victory-native'; +import { colors, spacing, borderRadius, typography } from '../../lib/theme'; + +interface PlatformChartProps { + data: { platform: string; count: number }[]; + height?: number; +} + +// Colors for pie slices - all visible against dark card background +const CHART_COLORS = [ + colors.cyan.core, // #18D1E7 - Cyan + colors.info, // #3B82F6 - Bright Blue (not blue.core which matches bg!) + colors.success, // #22C55E - Green + colors.warning, // #F59E0B - Orange/Yellow + colors.purple, // #8B5CF6 - Purple + colors.error, // #EF4444 - Red +]; + +export function PlatformChart({ data }: PlatformChartProps) { + // Sort by count and take top 5 + const sortedData = [...data] + .sort((a, b) => b.count - a.count) + .slice(0, 5) + .map((d, index) => ({ + label: d.platform.replace('Plex for ', '').replace('Jellyfin ', ''), + value: d.count, + color: CHART_COLORS[index % CHART_COLORS.length], + })); + + if (sortedData.length === 0) { + return ( + + No platform data available + + ); + } + + const total = sortedData.reduce((sum, item) => sum + item.value, 0); + + return ( + + {/* Pie Chart */} + + + + + + + {/* Legend with percentages */} + + {sortedData.map((item) => ( + + + + {item.label} + + + {Math.round((item.value / total) * 100)}% + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.sm, + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + minHeight: 150, + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + chartContainer: { + height: 160, + }, + legend: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: spacing.md, + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border.dark, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + legendDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + legendText: { + fontSize: typography.fontSize.xs, + color: colors.text.muted.dark, + maxWidth: 60, + }, + legendPercent: { + fontSize: typography.fontSize.xs, + color: colors.text.secondary.dark, + fontWeight: '500', + }, +}); diff --git a/apps/mobile/src/components/charts/PlaysChart.tsx b/apps/mobile/src/components/charts/PlaysChart.tsx new file mode 100644 index 0000000..59e52aa --- /dev/null +++ b/apps/mobile/src/components/charts/PlaysChart.tsx @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ +/** + * Area chart showing plays over time with touch-to-reveal tooltip + */ +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { CartesianChart, Area, useChartPressState } from 'victory-native'; +import { Circle } from '@shopify/react-native-skia'; +import { useAnimatedReaction, runOnJS } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { colors, spacing, borderRadius, typography } from '../../lib/theme'; +import { useChartFont } from './useChartFont'; + +interface PlaysChartProps { + data: { date: string; count: number }[]; + height?: number; +} + +function ToolTip({ x, y }: { x: SharedValue; y: SharedValue }) { + return ; +} + +export function PlaysChart({ data, height = 200 }: PlaysChartProps) { + const font = useChartFont(10); + const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } }); + + // React state to display values (synced from SharedValues) + const [displayValue, setDisplayValue] = useState<{ + index: number; + count: number; + } | null>(null); + + // Transform data for victory-native + const chartData = data.map((d, index) => ({ + x: index, + count: d.count, + label: d.date, + })); + + // Sync SharedValue changes to React state + const updateDisplayValue = useCallback((index: number, count: number) => { + setDisplayValue({ index: Math.round(index), count: Math.round(count) }); + }, []); + + const clearDisplayValue = useCallback(() => { + setDisplayValue(null); + }, []); + + // Watch for changes in chart press state + useAnimatedReaction( + () => ({ + active: isActive, + x: state.x.value.value, + y: state.y.count.value.value, + }), + (current, previous) => { + if (current.active) { + runOnJS(updateDisplayValue)(current.x, current.y); + } else if (previous?.active && !current.active) { + runOnJS(clearDisplayValue)(); + } + }, + [isActive] + ); + + if (chartData.length === 0) { + return ( + + No play data available + + ); + } + + // Get date label from React state + const dateLabel = displayValue && chartData[displayValue.index]?.label + ? new Date(chartData[displayValue.index].label).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + : ''; + + return ( + + {/* Active value display */} + + {displayValue ? ( + <> + {displayValue.count} plays + {dateLabel} + + ) : null} + + + { + const item = chartData[Math.round(value)]; + if (!item) return ''; + const date = new Date(item.label); + return `${date.getMonth() + 1}/${date.getDate()}`; + }, + formatYLabel: (value) => String(Math.round(value)), + }} + > + {({ points, chartBounds }) => ( + <> + + {isActive && ( + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.sm, + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + valueDisplay: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.xs, + marginBottom: spacing.xs, + minHeight: 20, + }, + valueText: { + color: colors.cyan.core, + fontSize: typography.fontSize.sm, + fontWeight: '600', + }, + dateText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.xs, + }, +}); diff --git a/apps/mobile/src/components/charts/QualityChart.tsx b/apps/mobile/src/components/charts/QualityChart.tsx new file mode 100644 index 0000000..0b81707 --- /dev/null +++ b/apps/mobile/src/components/charts/QualityChart.tsx @@ -0,0 +1,107 @@ +/** + * Simple chart showing direct play vs transcode breakdown + */ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { colors, spacing, borderRadius, typography } from '../../lib/theme'; + +interface QualityChartProps { + directPlay: number; + transcode: number; + directPlayPercent: number; + transcodePercent: number; + height?: number; +} + +export function QualityChart({ + directPlay, + transcode, + directPlayPercent, + transcodePercent, + height = 120, +}: QualityChartProps) { + const total = directPlay + transcode; + + if (total === 0) { + return ( + + No playback data available + + ); + } + + return ( + + {/* Progress bar */} + + + + + + {/* Legend */} + + + + Direct Play + {directPlay} ({directPlayPercent}%) + + + + Transcode + {transcode} ({transcodePercent}%) + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.md, + justifyContent: 'center', + }, + emptyContainer: { + alignItems: 'center', + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + barContainer: { + flexDirection: 'row', + height: 24, + borderRadius: borderRadius.md, + overflow: 'hidden', + marginBottom: spacing.md, + }, + directBar: { + backgroundColor: colors.success, + }, + transcodeBar: { + backgroundColor: colors.warning, + }, + legend: { + gap: spacing.sm, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + legendDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + legendLabel: { + flex: 1, + color: colors.text.primary.dark, + fontSize: typography.fontSize.sm, + }, + legendValue: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, +}); diff --git a/apps/mobile/src/components/charts/index.ts b/apps/mobile/src/components/charts/index.ts new file mode 100644 index 0000000..d286e24 --- /dev/null +++ b/apps/mobile/src/components/charts/index.ts @@ -0,0 +1,6 @@ +export { PlaysChart } from './PlaysChart'; +export { PlatformChart } from './PlatformChart'; +export { DayOfWeekChart } from './DayOfWeekChart'; +export { HourOfDayChart } from './HourOfDayChart'; +export { QualityChart } from './QualityChart'; +export { useChartFont } from './useChartFont'; diff --git a/apps/mobile/src/components/charts/useChartFont.ts b/apps/mobile/src/components/charts/useChartFont.ts new file mode 100644 index 0000000..e9295f6 --- /dev/null +++ b/apps/mobile/src/components/charts/useChartFont.ts @@ -0,0 +1,13 @@ +/** + * Hook to load fonts for chart axis labels + * Uses @shopify/react-native-skia's useFont with Inter font + */ +import { useFont } from '@shopify/react-native-skia'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const InterMedium = require('../../../assets/fonts/Inter_500Medium.ttf') as number; + +export function useChartFont(size: number = 10) { + const font = useFont(InterMedium, size); + return font; +} diff --git a/apps/mobile/src/components/map/StreamMap.tsx b/apps/mobile/src/components/map/StreamMap.tsx new file mode 100644 index 0000000..306d515 --- /dev/null +++ b/apps/mobile/src/components/map/StreamMap.tsx @@ -0,0 +1,207 @@ +/** + * Interactive map showing active stream locations + * Uses expo-maps with Apple Maps on iOS, Google Maps on Android + * + * Note: expo-maps doesn't support custom tile providers, so we can't + * match the web's dark theme exactly. Using default map styles. + */ +import React, { Component, type ReactNode } from 'react'; +import { View, Text, StyleSheet, Platform } from 'react-native'; +import { AppleMaps, GoogleMaps } from 'expo-maps'; +import { Ionicons } from '@expo/vector-icons'; +import type { ActiveSession } from '@tracearr/shared'; +import { colors, borderRadius, typography } from '../../lib/theme'; + +/** + * Error boundary to catch map crashes (e.g., missing Google Maps API key on Android) + * This prevents the entire app from crashing if the map fails to render + */ +class MapErrorBoundary extends Component< + { children: ReactNode; height: number }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode; height: number }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('StreamMap crashed:', error.message); + console.error('Component stack:', errorInfo.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + + + Map unavailable + {__DEV__ && this.state.error && ( + {this.state.error.message} + )} + + ); + } + return this.props.children; + } +} + +interface StreamMapProps { + sessions: ActiveSession[]; + height?: number; +} + +/** Session with guaranteed geo coordinates */ +type SessionWithLocation = ActiveSession & { + geoLat: number; + geoLon: number; +}; + +/** Type guard to filter sessions with valid coordinates */ +function hasLocation(session: ActiveSession): session is SessionWithLocation { + return session.geoLat != null && session.geoLon != null; +} + +export function StreamMap({ sessions, height = 300 }: StreamMapProps) { + // Filter sessions with valid geo coordinates (type guard narrows to SessionWithLocation[]) + const sessionsWithLocation = sessions.filter(hasLocation); + + if (sessionsWithLocation.length === 0) { + return ( + + No location data available + + ); + } + + // Calculate center point from all sessions + const avgLat = sessionsWithLocation.reduce((sum, s) => sum + s.geoLat, 0) / sessionsWithLocation.length; + const avgLon = sessionsWithLocation.reduce((sum, s) => sum + s.geoLon, 0) / sessionsWithLocation.length; + + // Create markers for each session with enhanced info + const markers = sessionsWithLocation.map((session) => { + const username = session.user?.username || 'Unknown'; + const location = [session.geoCity, session.geoCountry].filter(Boolean).join(', ') || 'Unknown location'; + const mediaTitle = session.mediaTitle || 'Unknown'; + + // Truncate long media titles for snippet + const truncatedTitle = mediaTitle.length > 40 + ? mediaTitle.substring(0, 37) + '...' + : mediaTitle; + + return { + id: session.sessionKey || session.id, + coordinates: { + latitude: session.geoLat, + longitude: session.geoLon, + }, + // Title shows username prominently + title: username, + // Snippet shows media and location + snippet: `${truncatedTitle}\n${location}`, + // Use cyan tint to match app theme + tintColor: colors.cyan.core, + // iOS: Use SF Symbol for streaming indicator + ...(Platform.OS === 'ios' && { + systemImage: 'play.circle.fill', + }), + }; + }); + + // Calculate appropriate zoom based on marker spread + const calculateZoom = () => { + if (sessionsWithLocation.length === 1) return 10; + + // Calculate spread of coordinates + const lats = sessionsWithLocation.map(s => s.geoLat); + const lons = sessionsWithLocation.map(s => s.geoLon); + const latSpread = Math.max(...lats) - Math.min(...lats); + const lonSpread = Math.max(...lons) - Math.min(...lons); + const maxSpread = Math.max(latSpread, lonSpread); + + // Adjust zoom based on spread + if (maxSpread > 100) return 2; + if (maxSpread > 50) return 3; + if (maxSpread > 20) return 4; + if (maxSpread > 10) return 5; + if (maxSpread > 5) return 6; + if (maxSpread > 1) return 8; + return 10; + }; + + const cameraPosition = { + coordinates: { + latitude: avgLat || 39.8283, + longitude: avgLon || -98.5795, + }, + zoom: calculateZoom(), + }; + + // Use platform-specific map component + const MapComponent = Platform.OS === 'ios' ? AppleMaps.View : GoogleMaps.View; + + return ( + + + ({ + id: m.id, + coordinates: m.coordinates, + title: m.title, + snippet: m.snippet, + tintColor: m.tintColor, + ...(Platform.OS === 'ios' && m.systemImage && { systemImage: m.systemImage }), + }))} + uiSettings={{ + compassEnabled: false, + scaleBarEnabled: false, + rotationGesturesEnabled: false, + tiltGesturesEnabled: false, + }} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: borderRadius.lg, + overflow: 'hidden', + backgroundColor: colors.card.dark, + }, + map: { + flex: 1, + }, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + }, + errorContainer: { + justifyContent: 'center', + alignItems: 'center', + gap: 8, + }, + errorText: { + color: colors.text.muted.dark, + fontSize: typography.fontSize.sm, + fontWeight: '500', + }, + errorDetail: { + color: colors.error, + fontSize: typography.fontSize.xs, + textAlign: 'center', + paddingHorizontal: 16, + marginTop: 4, + }, +}); diff --git a/apps/mobile/src/components/server/ServerResourceCard.tsx b/apps/mobile/src/components/server/ServerResourceCard.tsx new file mode 100644 index 0000000..d5cc275 --- /dev/null +++ b/apps/mobile/src/components/server/ServerResourceCard.tsx @@ -0,0 +1,246 @@ +/** + * Server resource monitoring card (CPU + RAM) + * Displays real-time server resource utilization with progress bars + * Note: Section header is rendered by parent - this is just the card content + */ +import { View, Animated, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useEffect, useRef } from 'react'; +import { Text } from '@/components/ui/text'; +import { colors, spacing, borderRadius, typography } from '@/lib/theme'; + +// Bar colors matching web app +const BAR_COLORS = { + process: '#00b4e4', // Plex-style cyan for "Plex Media Server" + system: '#cc7b9f', // Pink/purple for "System" +}; + +interface ResourceBarProps { + label: string; + processValue: number; + systemValue: number; + icon: keyof typeof Ionicons.glyphMap; +} + +function ResourceBar({ label, processValue, systemValue, icon }: ResourceBarProps) { + const processWidth = useRef(new Animated.Value(0)).current; + const systemWidth = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(processWidth, { + toValue: processValue, + duration: 300, + useNativeDriver: false, + }), + Animated.timing(systemWidth, { + toValue: systemValue, + duration: 300, + useNativeDriver: false, + }), + ]).start(); + }, [processValue, systemValue, processWidth, systemWidth]); + + return ( + + {/* Header row */} + + + {label} + + + {/* Process bar (Plex Media Server) */} + + + Plex Media Server + {processValue}% + + + + + + + {/* System bar */} + + + System + {systemValue}% + + + + + + + ); +} + +interface ServerResourceCardProps { + latest: { + hostCpu: number; + processCpu: number; + hostMemory: number; + processMemory: number; + } | null; + isLoading?: boolean; + error?: Error | null; +} + +export function ServerResourceCard({ latest, isLoading, error }: ServerResourceCardProps) { + if (isLoading) { + return ( + + + Loading... + + + ); + } + + if (error) { + return ( + + + + + + Failed to load + {error.message} + + + ); + } + + if (!latest) { + return ( + + + + + + No resource data + Waiting for server statistics... + + + ); + } + + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + padding: spacing.sm, + }, + loadingContainer: { + height: 80, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + fontSize: typography.fontSize.xs, + color: colors.text.muted.dark, + }, + emptyContainer: { + paddingVertical: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + }, + emptyIconContainer: { + backgroundColor: colors.surface.dark, + padding: spacing.sm, + borderRadius: borderRadius.full, + marginBottom: spacing.sm, + }, + emptyText: { + fontSize: typography.fontSize.sm, + fontWeight: '600', + color: colors.text.primary.dark, + }, + emptySubtext: { + fontSize: typography.fontSize.xs, + color: colors.text.muted.dark, + marginTop: 2, + }, + resourceBar: { + marginBottom: spacing.sm, + }, + resourceHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xs, + }, + resourceLabel: { + marginLeft: spacing.xs, + fontSize: typography.fontSize.xs, + fontWeight: '600', + color: colors.text.primary.dark, + }, + barSection: { + marginBottom: spacing.xs, + }, + barLabelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 3, + }, + barLabelText: { + fontSize: 10, + color: colors.text.muted.dark, + }, + barValueText: { + fontSize: 10, + fontWeight: '600', + color: colors.text.primary.dark, + }, + barTrack: { + height: 4, + backgroundColor: colors.surface.dark, + borderRadius: 2, + overflow: 'hidden', + }, + barFill: { + height: '100%', + borderRadius: 2, + }, +}); diff --git a/apps/mobile/src/components/sessions/NowPlayingCard.tsx b/apps/mobile/src/components/sessions/NowPlayingCard.tsx new file mode 100644 index 0000000..5dd7e33 --- /dev/null +++ b/apps/mobile/src/components/sessions/NowPlayingCard.tsx @@ -0,0 +1,256 @@ +/** + * Compact card showing an active streaming session + * Displays poster, title, user, progress bar, and play/pause status + */ +import React from 'react'; +import { View, Image, Pressable, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/ui/text'; +import { UserAvatar } from '@/components/ui/user-avatar'; +import { useAuthStore } from '@/lib/authStore'; +import { useEstimatedProgress } from '@/hooks/useEstimatedProgress'; +import { colors, spacing, borderRadius, typography } from '@/lib/theme'; +import type { ActiveSession } from '@tracearr/shared'; + +interface NowPlayingCardProps { + session: ActiveSession; + onPress?: (session: ActiveSession) => void; +} + +/** + * Format duration in ms to readable string (HH:MM:SS or MM:SS) + */ +function formatDuration(ms: number | null): string { + if (!ms) return '--:--'; + const seconds = Math.floor(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Get display title for media (handles TV shows vs movies) + */ +function getMediaDisplay(session: ActiveSession): { title: string; subtitle: string | null } { + if (session.mediaType === 'episode' && session.grandparentTitle) { + // TV Show episode + const episodeInfo = + session.seasonNumber && session.episodeNumber + ? `S${session.seasonNumber.toString().padStart(2, '0')}E${session.episodeNumber.toString().padStart(2, '0')}` + : ''; + return { + title: session.grandparentTitle, + subtitle: episodeInfo ? `${episodeInfo} · ${session.mediaTitle}` : session.mediaTitle, + }; + } + // Movie or music + return { + title: session.mediaTitle, + subtitle: session.year ? `${session.year}` : null, + }; +} + +export function NowPlayingCard({ session, onPress }: NowPlayingCardProps) { + const { serverUrl } = useAuthStore(); + const { title, subtitle } = getMediaDisplay(session); + + // Use estimated progress for smooth updates between SSE/poll events + const { estimatedProgressMs, progressPercent } = useEstimatedProgress(session); + + // Build poster URL using image proxy + const posterUrl = + serverUrl && session.thumbPath + ? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=80&height=120` + : null; + + const isPaused = session.state === 'paused'; + const username = session.user?.username || 'Unknown'; + const userThumbUrl = session.user?.thumbUrl || null; + + return ( + [styles.container, pressed && styles.pressed]} + onPress={() => onPress?.(session)} + > + {/* Main content row */} + + {/* Poster */} + + {posterUrl ? ( + + ) : ( + + + + )} + {/* Paused overlay */} + {isPaused && ( + + + + )} + + + {/* Info section */} + + {/* Title + subtitle */} + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {/* User + time row combined */} + + + + + {username} + + {session.isTranscode && ( + + )} + + + + + + + {isPaused + ? 'Paused' + : `${formatDuration(estimatedProgressMs)} / ${formatDuration(session.totalDurationMs)}`} + + + + + + {/* Chevron */} + + + + + + {/* Bottom progress bar - full width */} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.card.dark, + borderRadius: borderRadius.lg, + marginBottom: spacing.sm, + overflow: 'hidden', + }, + pressed: { + opacity: 0.7, + }, + contentRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + posterContainer: { + position: 'relative', + marginRight: spacing.sm, + }, + poster: { + width: 50, + height: 75, + borderRadius: borderRadius.md, + backgroundColor: colors.surface.dark, + }, + posterPlaceholder: { + justifyContent: 'center', + alignItems: 'center', + }, + pausedOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: borderRadius.md, + justifyContent: 'center', + alignItems: 'center', + }, + info: { + flex: 1, + justifyContent: 'center', + gap: 2, + }, + title: { + fontSize: typography.fontSize.sm, + fontWeight: '600', + color: colors.text.primary.dark, + lineHeight: 16, + }, + subtitle: { + fontSize: typography.fontSize.xs, + color: colors.text.muted.dark, + }, + userTimeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 2, + }, + userSection: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + flex: 1, + }, + username: { + fontSize: typography.fontSize.xs, + color: colors.text.secondary.dark, + }, + timeSection: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + statusDot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: 'rgba(24, 209, 231, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + statusDotPaused: { + backgroundColor: 'rgba(245, 158, 11, 0.15)', + }, + timeText: { + fontSize: typography.fontSize.xs, + color: colors.text.muted.dark, + }, + pausedText: { + color: colors.warning, + }, + progressBar: { + height: 3, + backgroundColor: colors.surface.dark, + }, + progressFill: { + height: '100%', + backgroundColor: colors.cyan.core, + }, + chevron: { + marginLeft: 4, + opacity: 0.5, + }, +}); diff --git a/apps/mobile/src/components/sessions/index.ts b/apps/mobile/src/components/sessions/index.ts new file mode 100644 index 0000000..54ac21d --- /dev/null +++ b/apps/mobile/src/components/sessions/index.ts @@ -0,0 +1 @@ +export { NowPlayingCard } from './NowPlayingCard'; diff --git a/apps/mobile/src/components/ui/badge.tsx b/apps/mobile/src/components/ui/badge.tsx new file mode 100644 index 0000000..8830860 --- /dev/null +++ b/apps/mobile/src/components/ui/badge.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { View, type ViewProps } from 'react-native'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { Text } from './text'; + +const badgeVariants = cva('flex-row items-center rounded-full px-2.5 py-0.5', { + variants: { + variant: { + default: 'bg-primary', + secondary: 'bg-secondary', + destructive: 'bg-destructive', + outline: 'border border-border bg-transparent', + success: 'bg-success/15', + warning: 'bg-warning/15', + danger: 'bg-danger/15', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +const badgeTextVariants = cva('text-xs font-semibold', { + variants: { + variant: { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + destructive: 'text-destructive-foreground', + outline: 'text-foreground', + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +interface BadgeProps extends ViewProps, VariantProps { + children: React.ReactNode; +} + +const Badge: React.FC = ({ className, variant, children, ...props }) => ( + + {typeof children === 'string' ? ( + {children} + ) : ( + children + )} + +); + +export { Badge, badgeVariants, badgeTextVariants }; diff --git a/apps/mobile/src/components/ui/button.tsx b/apps/mobile/src/components/ui/button.tsx new file mode 100644 index 0000000..fcaf267 --- /dev/null +++ b/apps/mobile/src/components/ui/button.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Pressable, type PressableProps } from 'react-native'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { Text } from './text'; + +const buttonVariants = cva('flex-row items-center justify-center rounded-md', { + variants: { + variant: { + default: 'bg-primary', + destructive: 'bg-destructive', + outline: 'border border-border bg-transparent', + secondary: 'bg-secondary', + ghost: 'bg-transparent', + link: 'bg-transparent', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 px-3', + lg: 'h-11 px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +const buttonTextVariants = cva('font-medium', { + variants: { + variant: { + default: 'text-primary-foreground', + destructive: 'text-destructive-foreground', + outline: 'text-foreground', + secondary: 'text-secondary-foreground', + ghost: 'text-foreground', + link: 'text-primary underline', + }, + size: { + default: 'text-sm', + sm: 'text-xs', + lg: 'text-base', + icon: 'text-sm', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +interface ButtonProps extends PressableProps, VariantProps { + children: React.ReactNode; +} + +const Button = React.forwardRef, ButtonProps>( + ({ className, variant, size, children, ...props }, ref) => ( + + {typeof children === 'string' ? ( + {children} + ) : ( + children + )} + + ) +); +Button.displayName = 'Button'; + +export { Button, buttonVariants, buttonTextVariants }; diff --git a/apps/mobile/src/components/ui/card.tsx b/apps/mobile/src/components/ui/card.tsx new file mode 100644 index 0000000..56fcb31 --- /dev/null +++ b/apps/mobile/src/components/ui/card.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { View, type ViewProps, type Text as RNText, type TextProps } from 'react-native'; +import { cn } from '@/lib/utils'; +import { Text } from './text'; + +const Card = React.forwardRef(({ className, ...props }, ref) => ( + +)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( + +)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/apps/mobile/src/components/ui/index.ts b/apps/mobile/src/components/ui/index.ts new file mode 100644 index 0000000..71b56fa --- /dev/null +++ b/apps/mobile/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { Text } from './text'; +export { Button, buttonVariants, buttonTextVariants } from './button'; +export { Badge, badgeVariants, badgeTextVariants } from './badge'; +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card'; diff --git a/apps/mobile/src/components/ui/period-selector.tsx b/apps/mobile/src/components/ui/period-selector.tsx new file mode 100644 index 0000000..7e22c3b --- /dev/null +++ b/apps/mobile/src/components/ui/period-selector.tsx @@ -0,0 +1,68 @@ +/** + * Segmented control for selecting time periods (7d, 30d, 1y) + */ +import React from 'react'; +import { View, Pressable, StyleSheet } from 'react-native'; +import { Text } from './text'; +import { colors, spacing, borderRadius } from '@/lib/theme'; + +export type StatsPeriod = 'week' | 'month' | 'year'; + +interface PeriodSelectorProps { + value: StatsPeriod; + onChange: (value: StatsPeriod) => void; +} + +const PERIODS: { value: StatsPeriod; label: string }[] = [ + { value: 'week', label: '7d' }, + { value: 'month', label: '30d' }, + { value: 'year', label: '1y' }, +]; + +export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { + return ( + + {PERIODS.map((period) => { + const isSelected = value === period.value; + return ( + onChange(period.value)} + style={[styles.button, isSelected && styles.buttonSelected]} + > + + {period.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: colors.surface.dark, + borderRadius: borderRadius.lg, + padding: 4, + }, + button: { + paddingHorizontal: spacing.md, + paddingVertical: (spacing.xs as number) + 2, + borderRadius: borderRadius.md, + }, + buttonSelected: { + backgroundColor: colors.card.dark, + }, + buttonText: { + fontSize: 13, + fontWeight: '500', + color: colors.text.muted.dark, + }, + buttonTextSelected: { + color: colors.text.primary.dark, + }, +}); diff --git a/apps/mobile/src/components/ui/text.tsx b/apps/mobile/src/components/ui/text.tsx new file mode 100644 index 0000000..c35c767 --- /dev/null +++ b/apps/mobile/src/components/ui/text.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Text as RNText, type TextProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +const Text = React.forwardRef(({ className, ...props }, ref) => ( + +)); +Text.displayName = 'Text'; + +export { Text }; diff --git a/apps/mobile/src/components/ui/user-avatar.tsx b/apps/mobile/src/components/ui/user-avatar.tsx new file mode 100644 index 0000000..27b1197 --- /dev/null +++ b/apps/mobile/src/components/ui/user-avatar.tsx @@ -0,0 +1,68 @@ +/** + * User avatar component with image and fallback to initials + */ +import React from 'react'; +import { View, Image, StyleSheet } from 'react-native'; +import { Text } from './text'; +import { colors } from '@/lib/theme'; + +interface UserAvatarProps { + /** User's avatar URL (can be null) */ + thumbUrl?: string | null; + /** Username for generating initials fallback */ + username: string; + /** Size of the avatar (default: 40) */ + size?: number; +} + +export function UserAvatar({ thumbUrl, username, size = 40 }: UserAvatarProps) { + const initials = username.slice(0, 2).toUpperCase(); + const fontSize = Math.max(size * 0.4, 10); + const borderRadiusValue = size / 2; + + if (thumbUrl) { + return ( + + ); + } + + return ( + + {initials} + + ); +} + +const styles = StyleSheet.create({ + image: { + backgroundColor: colors.surface.dark, + }, + fallback: { + backgroundColor: colors.cyan.dark, + justifyContent: 'center', + alignItems: 'center', + }, + initials: { + fontWeight: '600', + color: colors.text.primary.dark, + }, +}); diff --git a/apps/mobile/src/hooks/useEstimatedProgress.ts b/apps/mobile/src/hooks/useEstimatedProgress.ts new file mode 100644 index 0000000..a82a25e --- /dev/null +++ b/apps/mobile/src/hooks/useEstimatedProgress.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useRef } from 'react'; +import type { ActiveSession } from '@tracearr/shared'; + +/** + * Hook that estimates playback progress client-side for smooth UI updates. + * + * When state is "playing", progress increments every second based on elapsed time. + * When state is "paused" or "stopped", progress stays at last known value. + * + * Resets estimation when: + * - Session ID changes + * - Server-side progressMs changes (new data from SSE/poll) + * - State changes + * + * @param session - The active session to estimate progress for + * @returns Object with estimated progressMs and progress percentage + */ +export function useEstimatedProgress(session: ActiveSession) { + const [estimatedProgressMs, setEstimatedProgressMs] = useState(session.progressMs ?? 0); + + // Track the last known server values to detect changes + const lastServerProgress = useRef(session.progressMs); + const lastSessionId = useRef(session.id); + const lastState = useRef(session.state); + const estimationStartTime = useRef(Date.now()); + const estimationStartProgress = useRef(session.progressMs ?? 0); + + // Reset estimation when server data changes + useEffect(() => { + const serverProgressChanged = session.progressMs !== lastServerProgress.current; + const sessionChanged = session.id !== lastSessionId.current; + const stateChanged = session.state !== lastState.current; + + if (sessionChanged || serverProgressChanged || stateChanged) { + // Reset to server value + const newProgress = session.progressMs ?? 0; + setEstimatedProgressMs(newProgress); + + // Update refs + lastServerProgress.current = session.progressMs; + lastSessionId.current = session.id; + lastState.current = session.state; + estimationStartTime.current = Date.now(); + estimationStartProgress.current = newProgress; + } + }, [session.id, session.progressMs, session.state]); + + // Tick progress when playing + useEffect(() => { + if (session.state !== 'playing') { + return; + } + + const intervalId = setInterval(() => { + const elapsedMs = Date.now() - estimationStartTime.current; + const estimated = estimationStartProgress.current + elapsedMs; + + // Cap at total duration if available + const maxProgress = session.totalDurationMs ?? Infinity; + setEstimatedProgressMs(Math.min(estimated, maxProgress)); + }, 1000); + + return () => clearInterval(intervalId); + }, [session.state, session.totalDurationMs]); + + // Calculate percentage + const progressPercent = session.totalDurationMs + ? Math.min((estimatedProgressMs / session.totalDurationMs) * 100, 100) + : 0; + + return { + estimatedProgressMs, + progressPercent, + isEstimating: session.state === 'playing', + }; +} diff --git a/apps/mobile/src/hooks/usePushNotifications.ts b/apps/mobile/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..671ba6d --- /dev/null +++ b/apps/mobile/src/hooks/usePushNotifications.ts @@ -0,0 +1,304 @@ +/** + * Push notifications hook for violation alerts + * + * Handles push notification registration, foreground notifications, + * background task registration, and payload decryption. + */ +import { useEffect, useRef, useState, useCallback } from 'react'; +import * as Notifications from 'expo-notifications'; +import * as Device from 'expo-device'; +import Constants from 'expo-constants'; +import { Platform } from 'react-native'; +import { useRouter } from 'expo-router'; +import { useSocket } from '../providers/SocketProvider'; +import { useMediaServer } from '../providers/MediaServerProvider'; +import type { ViolationWithDetails, EncryptedPushPayload } from '@tracearr/shared'; +import { + registerBackgroundNotificationTask, + unregisterBackgroundNotificationTask, +} from '../lib/backgroundTasks'; +import { decryptPushPayload, isEncryptionAvailable, getDeviceSecret } from '../lib/crypto'; +import { api } from '../lib/api'; + +// Configure notification behavior +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +// Check if notification payload is encrypted +function isEncrypted(data: unknown): data is EncryptedPushPayload { + if (!data || typeof data !== 'object') return false; + const payload = data as Record; + return ( + payload.v === 1 && + typeof payload.iv === 'string' && + typeof payload.ct === 'string' && + typeof payload.tag === 'string' + ); +} + +export function usePushNotifications() { + const [expoPushToken, setExpoPushToken] = useState(null); + const [notification, setNotification] = useState(null); + const notificationListener = useRef(null); + const responseListener = useRef(null); + const router = useRouter(); + const { socket } = useSocket(); + const { selectServer, servers } = useMediaServer(); + + // Register for push notifications + const registerForPushNotifications = useCallback(async (): Promise => { + if (!Device.isDevice) { + console.log('Push notifications require a physical device'); + return null; + } + + // Check existing permissions + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + // Request permissions if not granted + if (existingStatus !== Notifications.PermissionStatus.GRANTED) { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== Notifications.PermissionStatus.GRANTED) { + console.log('Push notification permission not granted'); + return null; + } + + // Get Expo push token + try { + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + if (!projectId) { + console.error('No EAS project ID found in app config'); + return null; + } + const tokenData = await Notifications.getExpoPushTokenAsync({ projectId }); + return tokenData.data; + } catch (error) { + console.error('Failed to get push token:', error); + return null; + } + }, []); + + // Show local notification for violations + const showViolationNotification = useCallback(async (violation: ViolationWithDetails) => { + const ruleTypeLabels: Record = { + impossible_travel: 'Impossible Travel', + simultaneous_locations: 'Simultaneous Locations', + device_velocity: 'Device Velocity', + concurrent_streams: 'Concurrent Streams', + geo_restriction: 'Geo Restriction', + }; + + const severityLabels: Record = { + low: 'Low', + warning: 'Warning', + high: 'High', + critical: 'Critical', + }; + + const title = `${severityLabels[violation.severity] || 'Warning'} Violation`; + const ruleType = violation.rule?.type || ''; + const body = `${violation.user?.username || 'Unknown user'}: ${ruleTypeLabels[ruleType] || 'Rule Violation'}`; + + await Notifications.scheduleNotificationAsync({ + content: { + title, + body, + data: { + type: 'violation', + violationId: violation.id, + serverUserId: violation.serverUserId, + }, + sound: true, + }, + trigger: null, // Show immediately + }); + }, []); + + // Process notification data (handle encryption if needed) + const processNotificationData = useCallback( + async (data: Record): Promise> => { + if (isEncrypted(data) && isEncryptionAvailable()) { + try { + return await decryptPushPayload(data); + } catch (error) { + console.error('Failed to decrypt notification:', error); + return data; // Fall back to encrypted data + } + } + return data; + }, + [] + ); + + // Initialize push notifications + useEffect(() => { + const initializePushNotifications = async () => { + const token = await registerForPushNotifications(); + if (token) { + setExpoPushToken(token); + + // Register push token with server, including device secret for encryption + try { + const deviceSecret = isEncryptionAvailable() ? await getDeviceSecret() : undefined; + await api.registerPushToken(token, deviceSecret); + console.log('Push token registered with server'); + } catch (error) { + console.error('Failed to register push token with server:', error); + } + } + }; + + void initializePushNotifications(); + + // Register background notification task + void registerBackgroundNotificationTask(); + + // Listen for notifications received while app is foregrounded + notificationListener.current = Notifications.addNotificationReceivedListener( + (receivedNotification) => { + // Process/decrypt the notification data if needed + const rawData = receivedNotification.request.content.data; + if (rawData && typeof rawData === 'object') { + void (async () => { + const processedData = await processNotificationData( + rawData as Record + ); + // Update the notification with processed data + const processedNotification = { + ...receivedNotification, + request: { + ...receivedNotification.request, + content: { + ...receivedNotification.request.content, + data: processedData, + }, + }, + }; + setNotification(processedNotification as Notifications.Notification); + })(); + } else { + setNotification(receivedNotification); + } + } + ); + + // Listen for notification taps + responseListener.current = Notifications.addNotificationResponseReceivedListener( + (response) => { + const rawData = response.notification.request.content.data; + + void (async () => { + let data = rawData; + + // Decrypt if needed + if (rawData && isEncrypted(rawData) && isEncryptionAvailable()) { + try { + data = await decryptPushPayload(rawData); + } catch { + // Use raw data if decryption fails + } + } + + // Auto-select the server related to this notification if provided + const notificationServerId = data?.serverId as string | undefined; + if (notificationServerId && servers.some((s) => s.id === notificationServerId)) { + selectServer(notificationServerId); + } + + // Navigate based on notification type + if (data?.type === 'violation_detected') { + router.push('/(tabs)/alerts'); + } else if (data?.type === 'stream_started' || data?.type === 'stream_stopped') { + router.push('/(tabs)/activity'); + } else if (data?.type === 'server_down' || data?.type === 'server_up') { + router.push('/(tabs)'); + } + })(); + } + ); + + return () => { + if (notificationListener.current) { + notificationListener.current.remove(); + } + if (responseListener.current) { + responseListener.current.remove(); + } + // Note: We don't unregister background task on unmount + // as it needs to persist for background notifications + }; + }, [registerForPushNotifications, router, processNotificationData, selectServer, servers]); + + // Listen for violation events from socket + useEffect(() => { + if (!socket) return; + + const handleViolation = (violation: ViolationWithDetails) => { + void showViolationNotification(violation); + }; + + socket.on('violation:new', handleViolation); + + return () => { + socket.off('violation:new', handleViolation); + }; + }, [socket, showViolationNotification]); + + // Configure Android notification channels for different notification types + useEffect(() => { + if (Platform.OS === 'android') { + // Violations channel - high priority + void Notifications.setNotificationChannelAsync('violations', { + name: 'Violation Alerts', + description: 'Alerts when rule violations are detected', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#22D3EE', + sound: 'default', + }); + + // Sessions channel - default priority + void Notifications.setNotificationChannelAsync('sessions', { + name: 'Stream Activity', + description: 'Notifications for stream start/stop events', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 100, 100, 100], + lightColor: '#10B981', + }); + + // Alerts channel - high priority (server status) + void Notifications.setNotificationChannelAsync('alerts', { + name: 'Server Alerts', + description: 'Server online/offline notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 500], + lightColor: '#EF4444', + sound: 'default', + }); + } + }, []); + + // Cleanup function for logout + const cleanup = useCallback(async () => { + await unregisterBackgroundNotificationTask(); + }, []); + + return { + expoPushToken, + notification, + showViolationNotification, + cleanup, + isEncryptionAvailable: isEncryptionAvailable(), + }; +} diff --git a/apps/mobile/src/hooks/useServerStatistics.ts b/apps/mobile/src/hooks/useServerStatistics.ts new file mode 100644 index 0000000..4e2eb32 --- /dev/null +++ b/apps/mobile/src/hooks/useServerStatistics.ts @@ -0,0 +1,126 @@ +/** + * Hook for fetching server resource statistics (CPU/RAM) + * Only polls when: + * 1. App is in foreground (AppState === 'active') + * 2. Dashboard tab is focused (useIsFocused) + */ +import { useRef, useCallback, useEffect, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { useIsFocused } from '@react-navigation/native'; +import { useQuery } from '@tanstack/react-query'; +import { SERVER_STATS_CONFIG, type ServerResourceDataPoint, type ServerResourceStats } from '@tracearr/shared'; +import { api } from '@/lib/api'; + +/** + * Hook for fetching server resource statistics with fixed 2-minute window + * Polls every 6 seconds, displays last 2 minutes of data (20 points) + * + * @param serverId - Server ID to fetch stats for + * @param enabled - Additional enable condition (e.g., server exists) + */ +export function useServerStatistics(serverId: string | undefined, enabled: boolean = true) { + const isFocused = useIsFocused(); + const [appState, setAppState] = useState(AppState.currentState); + + // Track app state changes + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + setAppState(nextAppState); + }); + + return () => { + subscription.remove(); + }; + }, []); + + // Only poll when app is active AND screen is focused + const shouldPoll = enabled && !!serverId && appState === 'active' && isFocused; + + // Accumulate data points across polls, keyed by timestamp for deduplication + const dataMapRef = useRef>(new Map()); + + // Merge new data with existing, keep most recent DATA_POINTS + const mergeData = useCallback((newData: ServerResourceDataPoint[]) => { + const map = dataMapRef.current; + + // Add/update data points + for (const point of newData) { + map.set(point.at, point); + } + + // Sort by timestamp descending (newest first), keep DATA_POINTS + const sorted = Array.from(map.values()) + .sort((a, b) => b.at - a.at) + .slice(0, SERVER_STATS_CONFIG.DATA_POINTS); + + // Rebuild map with only kept points + dataMapRef.current = new Map(sorted.map((p) => [p.at, p])); + + // Return in ascending order (oldest first) for chart rendering + return sorted.reverse(); + }, []); + + const query = useQuery({ + queryKey: ['servers', 'statistics', serverId], + queryFn: async (): Promise => { + if (!serverId) throw new Error('Server ID required'); + const response = await api.servers.statistics(serverId); + // Merge with accumulated data + const mergedData = mergeData(response.data); + return { + ...response, + data: mergedData, + }; + }, + enabled: shouldPoll, + // Poll every 6 seconds (matches SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS) + refetchInterval: shouldPoll ? SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000 : false, + // Keep previous data while fetching new + placeholderData: (prev) => prev, + // Data is fresh until next poll + staleTime: (SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000) - 500, + }); + + // Calculate averages from windowed data + const dataPoints = query.data?.data; + const dataLength = dataPoints?.length ?? 0; + const averages = dataPoints && dataLength > 0 + ? { + hostCpu: Math.round( + dataPoints.reduce((sum: number, p) => sum + p.hostCpuUtilization, 0) / dataLength + ), + processCpu: Math.round( + dataPoints.reduce((sum: number, p) => sum + p.processCpuUtilization, 0) / dataLength + ), + hostMemory: Math.round( + dataPoints.reduce((sum: number, p) => sum + p.hostMemoryUtilization, 0) / dataLength + ), + processMemory: Math.round( + dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength + ), + } + : null; + + // Get latest values (most recent data point) + const lastDataPoint = query.data?.data?.[query.data.data.length - 1]; + const latest = lastDataPoint + ? { + hostCpu: Math.round(lastDataPoint.hostCpuUtilization), + processCpu: Math.round(lastDataPoint.processCpuUtilization), + hostMemory: Math.round(lastDataPoint.hostMemoryUtilization), + processMemory: Math.round(lastDataPoint.processMemoryUtilization), + } + : null; + + return { + ...query, + averages, + latest, + isPolling: shouldPoll, + // Provide more specific loading state: + // - isLoading: true only on initial fetch with no cached data + // - isFetching: true during any fetch (initial or refetch) + // We want to show loading UI while waiting for first data + isLoadingData: query.isLoading || (query.isFetching && !query.data), + }; +} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts new file mode 100644 index 0000000..648ae94 --- /dev/null +++ b/apps/mobile/src/lib/api.ts @@ -0,0 +1,545 @@ +/** + * API client for Tracearr mobile app + * Uses axios with automatic token refresh + * Supports multiple servers with active server selection + */ +import axios from 'axios'; +import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { storage } from './storage'; +import type { + ActiveSession, + DashboardStats, + ServerUserWithIdentity, + ServerUserDetail, + Session, + SessionWithDetails, + UserLocation, + UserDevice, + Violation, + ViolationWithDetails, + Rule, + Server, + Settings, + MobilePairResponse, + PaginatedResponse, + NotificationPreferences, + NotificationPreferencesWithStatus, + ServerResourceStats, + TerminationLogWithDetails, +} from '@tracearr/shared'; + +// Cache of API clients per server +const apiClients = new Map(); +let activeServerId: string | null = null; + +/** + * Initialize or get the API client for the active server + */ +export async function getApiClient(): Promise { + const serverId = await storage.getActiveServerId(); + if (!serverId) { + throw new Error('No server configured'); + } + + // If server changed, update active + if (activeServerId !== serverId) { + activeServerId = serverId; + } + + // Check cache + const cached = apiClients.get(serverId); + if (cached) { + return cached; + } + + // Get server info + const server = await storage.getServer(serverId); + if (!server) { + throw new Error('Server not found'); + } + + const client = createApiClient(server.url, serverId); + apiClients.set(serverId, client); + return client; +} + +/** + * Create a new API client for a given server + */ +export function createApiClient(baseURL: string, serverId: string): AxiosInstance { + const client = axios.create({ + baseURL: `${baseURL}/api/v1`, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Request interceptor - add auth token for this server + client.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + const credentials = await storage.getServerCredentials(serverId); + if (credentials) { + config.headers.Authorization = `Bearer ${credentials.accessToken}`; + } + return config; + }, + (error: unknown) => Promise.reject(error instanceof Error ? error : new Error(String(error))) + ); + + // Response interceptor - handle token refresh for this server + client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + // If 401 and not already retrying, attempt token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const credentials = await storage.getServerCredentials(serverId); + if (!credentials?.refreshToken) { + throw new Error('No refresh token'); + } + + const response = await client.post<{ accessToken: string; refreshToken: string }>( + '/mobile/refresh', + { refreshToken: credentials.refreshToken } + ); + + await storage.updateServerTokens( + serverId, + response.data.accessToken, + response.data.refreshToken + ); + + // Retry original request with new token + originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`; + return await client(originalRequest); + } catch { + // Refresh failed - remove this server's client from cache + apiClients.delete(serverId); + throw new Error('Session expired'); + } + } + + return Promise.reject(error); + } + ); + + return client; +} + +/** + * Reset the API client cache (call when switching servers or logging out) + */ +export function resetApiClient(): void { + apiClients.clear(); + activeServerId = null; +} + +/** + * Remove a specific server's client from cache + */ +export function removeApiClient(serverId: string): void { + apiClients.delete(serverId); +} + +/** + * Get the current server URL (for building absolute URLs like images) + */ +export async function getServerUrl(): Promise { + return storage.getServerUrl(); +} + +/** + * API methods organized by domain + * All methods use the active server's client + */ +export const api = { + /** + * Pair with server using mobile token + * This is called before we have a client, so it uses direct axios + */ + pair: async ( + serverUrl: string, + token: string, + deviceName: string, + deviceId: string, + platform: 'ios' | 'android', + deviceSecret?: string + ): Promise => { + try { + const response = await axios.post( + `${serverUrl}/api/v1/mobile/pair`, + { token, deviceName, deviceId, platform, deviceSecret }, + { timeout: 15000 } + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + // Extract server's error message if available + const serverMessage = + error.response?.data?.message || + error.response?.data?.error; + + if (serverMessage) { + throw new Error(serverMessage); + } + + // Handle specific HTTP status codes + if (error.response?.status === 429) { + throw new Error('Too many pairing attempts. Please wait a few minutes.'); + } + if (error.response?.status === 401) { + throw new Error('Invalid or expired pairing token.'); + } + if (error.response?.status === 400) { + throw new Error('Invalid pairing request. Check your token.'); + } + + // Handle network errors + if (error.code === 'ECONNABORTED') { + throw new Error('Connection timed out. Check your server URL.'); + } + if (error.code === 'ERR_NETWORK' || !error.response) { + throw new Error('Cannot reach server. Check URL and network connection.'); + } + + // Fallback to axios message + throw new Error(error.message); + } + throw error; + } + }, + + /** + * Register push token for notifications + */ + registerPushToken: async ( + expoPushToken: string, + deviceSecret?: string + ): Promise<{ success: boolean; updatedSessions: number }> => { + const client = await getApiClient(); + const response = await client.post<{ success: boolean; updatedSessions: number }>( + '/mobile/push-token', + { expoPushToken, deviceSecret } + ); + return response.data; + }, + + /** + * Dashboard stats + */ + stats: { + dashboard: async (serverId?: string): Promise => { + const client = await getApiClient(); + const response = await client.get('/stats/dashboard', { + params: serverId ? { serverId } : undefined, + }); + return response.data; + }, + plays: async (params?: { + period?: string; + serverId?: string; + }): Promise<{ data: { date: string; count: number }[] }> => { + const client = await getApiClient(); + const response = await client.get<{ data: { date: string; count: number }[] }>( + '/stats/plays', + { params } + ); + return response.data; + }, + playsByDayOfWeek: async (params?: { + period?: string; + serverId?: string; + }): Promise<{ data: { day: number; name: string; count: number }[] }> => { + const client = await getApiClient(); + const response = await client.get<{ data: { day: number; name: string; count: number }[] }>( + '/stats/plays-by-dayofweek', + { params } + ); + return response.data; + }, + playsByHourOfDay: async (params?: { + period?: string; + serverId?: string; + }): Promise<{ data: { hour: number; count: number }[] }> => { + const client = await getApiClient(); + const response = await client.get<{ data: { hour: number; count: number }[] }>( + '/stats/plays-by-hourofday', + { params } + ); + return response.data; + }, + platforms: async (params?: { + period?: string; + serverId?: string; + }): Promise<{ data: { platform: string; count: number }[] }> => { + const client = await getApiClient(); + const response = await client.get<{ data: { platform: string; count: number }[] }>( + '/stats/platforms', + { params } + ); + return response.data; + }, + quality: async (params?: { period?: string; serverId?: string }): Promise<{ + directPlay: number; + transcode: number; + total: number; + directPlayPercent: number; + transcodePercent: number; + }> => { + const client = await getApiClient(); + const response = await client.get<{ + directPlay: number; + transcode: number; + total: number; + directPlayPercent: number; + transcodePercent: number; + }>('/stats/quality', { params }); + return response.data; + }, + concurrent: async (params?: { + period?: string; + serverId?: string; + }): Promise<{ data: { hour: string; maxConcurrent: number }[] }> => { + const client = await getApiClient(); + const response = await client.get<{ data: { hour: string; maxConcurrent: number }[] }>( + '/stats/concurrent', + { params } + ); + return response.data; + }, + locations: async (params?: { + serverId?: string; + userId?: string; + }): Promise<{ + data: { + latitude: number; + longitude: number; + city: string; + country: string; + playCount: number; + }[]; + }> => { + const client = await getApiClient(); + const response = await client.get<{ + data: { + latitude: number; + longitude: number; + city: string; + country: string; + playCount: number; + }[]; + }>('/stats/locations', { params }); + return response.data; + }, + }, + + /** + * Sessions + */ + sessions: { + active: async (serverId?: string): Promise => { + const client = await getApiClient(); + const response = await client.get<{ data: ActiveSession[] }>('/sessions/active', { + params: serverId ? { serverId } : undefined, + }); + return response.data.data; + }, + list: async (params?: { + page?: number; + pageSize?: number; + userId?: string; + serverId?: string; + }) => { + const client = await getApiClient(); + const response = await client.get>('/sessions', { params }); + return response.data; + }, + get: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.get(`/sessions/${id}`); + return response.data; + }, + terminate: async ( + id: string, + reason?: string + ): Promise<{ success: boolean; terminationLogId: string; message: string }> => { + const client = await getApiClient(); + const response = await client.post<{ + success: boolean; + terminationLogId: string; + message: string; + }>(`/mobile/streams/${id}/terminate`, { reason }); + return response.data; + }, + }, + + /** + * Users + */ + users: { + list: async (params?: { page?: number; pageSize?: number; serverId?: string }) => { + const client = await getApiClient(); + const response = await client.get>('/users', { + params, + }); + return response.data; + }, + get: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.get(`/users/${id}`); + return response.data; + }, + sessions: async (id: string, params?: { page?: number; pageSize?: number }) => { + const client = await getApiClient(); + const response = await client.get>(`/users/${id}/sessions`, { + params, + }); + return response.data; + }, + locations: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.get<{ data: UserLocation[] }>(`/users/${id}/locations`); + return response.data.data; + }, + devices: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.get<{ data: UserDevice[] }>(`/users/${id}/devices`); + return response.data.data; + }, + terminations: async ( + id: string, + params?: { page?: number; pageSize?: number } + ): Promise> => { + const client = await getApiClient(); + const response = await client.get>( + `/users/${id}/terminations`, + { params } + ); + return response.data; + }, + }, + + /** + * Violations + */ + violations: { + list: async (params?: { + page?: number; + pageSize?: number; + userId?: string; + severity?: string; + acknowledged?: boolean; + serverId?: string; + }) => { + const client = await getApiClient(); + const response = await client.get>('/violations', { + params, + }); + return response.data; + }, + acknowledge: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.patch(`/violations/${id}`); + return response.data; + }, + dismiss: async (id: string): Promise => { + const client = await getApiClient(); + await client.delete(`/violations/${id}`); + }, + }, + + /** + * Rules + */ + rules: { + list: async (serverId?: string): Promise => { + const client = await getApiClient(); + const response = await client.get<{ data: Rule[] }>('/rules', { + params: serverId ? { serverId } : undefined, + }); + return response.data.data; + }, + toggle: async (id: string, isActive: boolean): Promise => { + const client = await getApiClient(); + const response = await client.patch(`/rules/${id}`, { isActive }); + return response.data; + }, + }, + + /** + * Servers + */ + servers: { + list: async (): Promise => { + const client = await getApiClient(); + const response = await client.get<{ data: Server[] }>('/servers'); + return response.data.data; + }, + statistics: async (id: string): Promise => { + const client = await getApiClient(); + const response = await client.get(`/servers/${id}/statistics`); + return response.data; + }, + }, + + /** + * Notification preferences (per-device settings) + */ + notifications: { + /** + * Get notification preferences for current device + * Returns preferences with live rate limit status from Redis + */ + getPreferences: async (): Promise => { + const client = await getApiClient(); + const response = await client.get( + '/notifications/preferences' + ); + return response.data; + }, + + /** + * Update notification preferences for current device + * Supports partial updates - only send fields you want to change + */ + updatePreferences: async ( + data: Partial< + Omit + > + ): Promise => { + const client = await getApiClient(); + const response = await client.patch( + '/notifications/preferences', + data + ); + return response.data; + }, + + /** + * Send a test notification to verify push is working + */ + sendTest: async (): Promise<{ success: boolean; message: string }> => { + const client = await getApiClient(); + const response = await client.post<{ success: boolean; message: string }>( + '/notifications/test' + ); + return response.data; + }, + }, + + /** + * Global settings (display preferences, etc.) + */ + settings: { + get: async (): Promise => { + const client = await getApiClient(); + const response = await client.get('/settings'); + return response.data; + }, + }, +}; diff --git a/apps/mobile/src/lib/authStore.ts b/apps/mobile/src/lib/authStore.ts new file mode 100644 index 0000000..d433105 --- /dev/null +++ b/apps/mobile/src/lib/authStore.ts @@ -0,0 +1,308 @@ +/** + * Authentication state store using Zustand + * Supports multiple server connections with active server selection + */ +import { create } from 'zustand'; +import { storage, type ServerInfo } from './storage'; +import { api, resetApiClient } from './api'; +import * as Device from 'expo-device'; +import { Platform } from 'react-native'; +import { isEncryptionAvailable, getDeviceSecret } from './crypto'; + +interface AuthState { + // Multi-server state + servers: ServerInfo[]; + activeServerId: string | null; + activeServer: ServerInfo | null; + + // Legacy compatibility + isAuthenticated: boolean; + isLoading: boolean; + serverUrl: string | null; + serverName: string | null; + error: string | null; + + // Actions + initialize: () => Promise; + pair: (serverUrl: string, token: string) => Promise; + addServer: (serverUrl: string, token: string) => Promise; + removeServer: (serverId: string) => Promise; + selectServer: (serverId: string) => Promise; + /** @deprecated Use removeServer(serverId) instead for clarity. This removes the active server. */ + logout: () => Promise; + removeActiveServer: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((set, get) => ({ + // Initial state + servers: [], + activeServerId: null, + activeServer: null, + isAuthenticated: false, + isLoading: true, + serverUrl: null, + serverName: null, + error: null, + + /** + * Initialize auth state from stored credentials + * Handles migration from legacy single-server storage + */ + initialize: async () => { + try { + set({ isLoading: true, error: null }); + + // Check for and migrate legacy storage + await storage.migrateFromLegacy(); + + // Load servers and active selection + const servers = await storage.getServers(); + const activeServerId = await storage.getActiveServerId(); + const activeServer = activeServerId + ? servers.find((s) => s.id === activeServerId) ?? null + : null; + + // If we have servers but no active selection, select first one + if (servers.length > 0 && !activeServer) { + const firstServer = servers[0]!; + await storage.setActiveServerId(firstServer.id); + set({ + servers, + activeServerId: firstServer.id, + activeServer: firstServer, + isAuthenticated: true, + serverUrl: firstServer.url, + serverName: firstServer.name, + isLoading: false, + }); + } else if (activeServer) { + set({ + servers, + activeServerId, + activeServer, + isAuthenticated: true, + serverUrl: activeServer.url, + serverName: activeServer.name, + isLoading: false, + }); + } else { + set({ + servers: [], + activeServerId: null, + activeServer: null, + isAuthenticated: false, + serverUrl: null, + serverName: null, + isLoading: false, + }); + } + } catch (error) { + console.error('Auth initialization failed:', error); + set({ + servers: [], + activeServerId: null, + activeServer: null, + isAuthenticated: false, + isLoading: false, + error: 'Failed to initialize authentication', + }); + } + }, + + /** + * Pair with server using mobile token (legacy method, adds as first/only server) + */ + pair: async (serverUrl: string, token: string) => { + // Delegate to addServer + await get().addServer(serverUrl, token); + }, + + /** + * Add a new server connection + */ + addServer: async (serverUrl: string, token: string) => { + try { + set({ isLoading: true, error: null }); + + // Get device info + const deviceName = + Device.deviceName || `${Device.brand || 'Unknown'} ${Device.modelName || 'Device'}`; + const deviceId = Device.osBuildId || `${Platform.OS}-${Date.now()}`; + const platform = Platform.OS === 'ios' ? 'ios' : 'android'; + + // Normalize URL (remove trailing slash) + const normalizedUrl = serverUrl.replace(/\/$/, ''); + + // Get device secret for push notification encryption (if available) + let deviceSecret: string | undefined; + if (isEncryptionAvailable()) { + try { + deviceSecret = await getDeviceSecret(); + } catch (error) { + console.warn('Failed to get device secret for encryption:', error); + } + } + + // Call pair API + const response = await api.pair( + normalizedUrl, + token, + deviceName, + deviceId, + platform, + deviceSecret + ); + + // Create server info + const serverInfo: ServerInfo = { + id: response.server.id, + url: normalizedUrl, + name: response.server.name, + type: response.server.type, + addedAt: new Date().toISOString(), + }; + + // Store server and credentials + await storage.addServer(serverInfo, { + accessToken: response.accessToken, + refreshToken: response.refreshToken, + }); + + // Set as active server + await storage.setActiveServerId(serverInfo.id); + + // Reset API client to use new server + resetApiClient(); + + // Update state + const servers = await storage.getServers(); + set({ + servers, + activeServerId: serverInfo.id, + activeServer: serverInfo, + isAuthenticated: true, + serverUrl: normalizedUrl, + serverName: serverInfo.name, + isLoading: false, + }); + } catch (error) { + console.error('Adding server failed:', error); + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to add server. Check URL and token.', + }); + throw error; + } + }, + + /** + * Remove a server connection + */ + removeServer: async (serverId: string) => { + try { + set({ isLoading: true }); + + await storage.removeServer(serverId); + + // Reload state + const servers = await storage.getServers(); + const activeServerId = await storage.getActiveServerId(); + const activeServer = activeServerId + ? servers.find((s) => s.id === activeServerId) ?? null + : null; + + // Reset API client + resetApiClient(); + + if (servers.length === 0) { + set({ + servers: [], + activeServerId: null, + activeServer: null, + isAuthenticated: false, + serverUrl: null, + serverName: null, + isLoading: false, + error: null, + }); + } else { + set({ + servers, + activeServerId, + activeServer, + isAuthenticated: true, + serverUrl: activeServer?.url ?? null, + serverName: activeServer?.name ?? null, + isLoading: false, + }); + } + } catch (error) { + console.error('Removing server failed:', error); + set({ + isLoading: false, + error: 'Failed to remove server', + }); + } + }, + + /** + * Switch to a different server + */ + selectServer: async (serverId: string) => { + try { + const { servers } = get(); + const server = servers.find((s) => s.id === serverId); + + if (!server) { + throw new Error('Server not found'); + } + + // Set as active + await storage.setActiveServerId(serverId); + + // Reset API client to use new server + resetApiClient(); + + set({ + activeServerId: serverId, + activeServer: server, + serverUrl: server.url, + serverName: server.name, + }); + } catch (error) { + console.error('Selecting server failed:', error); + set({ + error: 'Failed to switch server', + }); + } + }, + + /** + * Remove the currently active server + * @deprecated Use removeServer(serverId) instead for clarity + */ + logout: async () => { + const { activeServerId } = get(); + if (activeServerId) { + await get().removeServer(activeServerId); + } + }, + + /** + * Remove the currently active server (alias for logout with clearer name) + */ + removeActiveServer: async () => { + const { activeServerId } = get(); + if (activeServerId) { + await get().removeServer(activeServerId); + } + }, + + /** + * Clear error message + */ + clearError: () => { + set({ error: null }); + }, +})); diff --git a/apps/mobile/src/lib/backgroundTasks.ts b/apps/mobile/src/lib/backgroundTasks.ts new file mode 100644 index 0000000..a8a8ecf --- /dev/null +++ b/apps/mobile/src/lib/backgroundTasks.ts @@ -0,0 +1,186 @@ +/** + * Background Task Handler for Push Notifications + * + * Handles push notifications when the app is in the background or killed. + * Uses expo-task-manager to register background tasks that process + * incoming notifications. + */ +import * as TaskManager from 'expo-task-manager'; +import * as Notifications from 'expo-notifications'; +import { decryptPushPayload, type DecryptedPayload } from './crypto'; +import type { EncryptedPushPayload } from '@tracearr/shared'; + +// Task identifier for background notification handling +export const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND_NOTIFICATION_TASK'; + +// Check if notification payload is encrypted +function isEncrypted(data: unknown): data is EncryptedPushPayload { + if (!data || typeof data !== 'object') return false; + const payload = data as Record; + return ( + payload.v === 1 && + typeof payload.iv === 'string' && + typeof payload.ct === 'string' && + typeof payload.tag === 'string' + ); +} + +/** + * Process notification payload (decrypt if needed) + */ +async function processPayload( + data: Record +): Promise { + // Check if payload is encrypted + if (isEncrypted(data)) { + try { + return await decryptPushPayload(data); + } catch (error) { + console.error('[BackgroundTask] Failed to decrypt payload:', error); + return null; + } + } + + // Not encrypted, return as-is + return data as DecryptedPayload; +} + +/** + * Handle different notification types in background + */ +async function handleNotificationType(payload: DecryptedPayload): Promise { + const notificationType = payload.type as string; + + // Handle based on notification type + if (notificationType === 'violation_detected') { + // Violation notifications are critical - ensure they're displayed + console.log('[BackgroundTask] Processing violation notification'); + } else if ( + notificationType === 'stream_started' || + notificationType === 'stream_stopped' + ) { + // Session notifications are informational + console.log('[BackgroundTask] Processing session notification'); + } else if ( + notificationType === 'server_down' || + notificationType === 'server_up' + ) { + // Server status notifications are important + console.log('[BackgroundTask] Processing server status notification'); + } else if (notificationType === 'data_sync') { + // Silent notification for background data refresh + const syncType = payload.syncType as string | undefined; + console.log('[BackgroundTask] Processing data sync request:', syncType); + + // Import QueryClient dynamically to avoid circular dependencies + try { + // Invalidate relevant query caches based on sync type + // The actual data will be refetched when the app becomes active + // This is handled by React Query's cache invalidation + if (syncType === 'stats') { + console.log('[BackgroundTask] Marking stats cache for refresh'); + // Stats will be refetched on next app focus + } else if (syncType === 'sessions') { + console.log('[BackgroundTask] Marking sessions cache for refresh'); + // Sessions will be refetched on next app focus + } + } catch (error) { + console.error('[BackgroundTask] Data sync error:', error); + } + } else { + console.log('[BackgroundTask] Unknown notification type:', notificationType); + } +} + +/** + * Define the background notification task + * + * This task is executed by the OS when a background notification arrives. + * It must complete within the OS-defined time limit (usually 30 seconds). + */ +TaskManager.defineTask( + BACKGROUND_NOTIFICATION_TASK, + async ({ data, error }: TaskManager.TaskManagerTaskBody) => { + if (error) { + console.error('[BackgroundTask] Error:', error); + return; + } + + if (!data) { + console.log('[BackgroundTask] No data received'); + return; + } + + try { + // Extract notification from task data + const taskData = data as { notification?: Notifications.Notification }; + const notificationData = taskData.notification?.request.content.data; + + if (!notificationData) { + console.log('[BackgroundTask] No notification data'); + return; + } + + // Process the payload (decrypt if encrypted) + const payload = await processPayload( + notificationData as Record + ); + if (!payload) { + console.error('[BackgroundTask] Failed to process payload'); + return; + } + + // Handle the notification based on type + await handleNotificationType(payload); + } catch (err) { + console.error('[BackgroundTask] Processing error:', err); + } + } +); + +/** + * Register the background notification task + * Call this during app initialization + */ +export async function registerBackgroundNotificationTask(): Promise { + try { + const isRegistered = await TaskManager.isTaskRegisteredAsync( + BACKGROUND_NOTIFICATION_TASK + ); + + if (!isRegistered) { + await Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK); + console.log('[BackgroundTask] Registered background notification task'); + } else { + console.log('[BackgroundTask] Background task already registered'); + } + } catch (error) { + console.error('[BackgroundTask] Failed to register task:', error); + } +} + +/** + * Unregister the background notification task + * Call this on logout/cleanup + */ +export async function unregisterBackgroundNotificationTask(): Promise { + try { + const isRegistered = await TaskManager.isTaskRegisteredAsync( + BACKGROUND_NOTIFICATION_TASK + ); + + if (isRegistered) { + await Notifications.unregisterTaskAsync(BACKGROUND_NOTIFICATION_TASK); + console.log('[BackgroundTask] Unregistered background notification task'); + } + } catch (error) { + console.error('[BackgroundTask] Failed to unregister task:', error); + } +} + +/** + * Check if background notifications are supported + */ +export function isBackgroundNotificationSupported(): boolean { + return TaskManager.isAvailableAsync !== undefined; +} diff --git a/apps/mobile/src/lib/crypto.ts b/apps/mobile/src/lib/crypto.ts new file mode 100644 index 0000000..faa98f4 --- /dev/null +++ b/apps/mobile/src/lib/crypto.ts @@ -0,0 +1,203 @@ +/** + * Push Notification Payload Encryption/Decryption + * + * Uses AES-256-GCM (Authenticated Encryption with Associated Data) + * for secure push notification payloads. + * + * Security properties: + * - Confidentiality: Only the intended device can read the payload + * - Integrity: Tampered payloads are detected and rejected + * - Per-device keys: Each device has a unique derived key + */ +import crypto from 'react-native-quick-crypto'; +import * as SecureStore from 'expo-secure-store'; +import type { EncryptedPushPayload, NotificationEventType } from '@tracearr/shared'; + +// Storage key for the per-device encryption secret +const DEVICE_SECRET_KEY = 'tracearr_device_secret'; + +// AES-256-GCM parameters +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 12; // 96 bits (recommended for GCM) +const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum) +const AUTH_TAG_LENGTH = 16; // 128 bits + +/** + * Decrypted push payload structure + */ +export interface DecryptedPayload { + type: NotificationEventType | 'data_sync'; + title?: string; + body?: string; + data?: Record; + [key: string]: unknown; +} + +/** + * Generate or retrieve the device-specific encryption secret + * This secret is used along with the server's key to derive the encryption key + */ +export async function getDeviceSecret(): Promise { + let secret = await SecureStore.getItemAsync(DEVICE_SECRET_KEY); + + if (!secret) { + // Generate a new 32-byte random secret + const randomBytes = crypto.randomBytes(32); + secret = Buffer.from(randomBytes).toString('base64'); + await SecureStore.setItemAsync(DEVICE_SECRET_KEY, secret); + console.log('[Crypto] Generated new device secret'); + } + + return secret; +} + +/** + * Derive the encryption key using PBKDF2 + * + * The key is derived from: + * - Device secret (stored locally) + * - Server key identifier (sent with encrypted payload) + * + * This ensures each device has a unique key. + */ +export async function deriveKey( + deviceSecret: string, + salt: Buffer +): Promise { + // Use PBKDF2 with 100,000 iterations for key derivation + // Note: react-native-quick-crypto uses uppercase hash names + const key = crypto.pbkdf2Sync( + deviceSecret, + salt, + 100000, + KEY_LENGTH, + 'SHA-256' + ); + + return Buffer.from(key); +} + +/** + * Decrypt an encrypted push notification payload + */ +export async function decryptPushPayload( + encrypted: EncryptedPushPayload +): Promise { + // Validate version + if (encrypted.v !== 1) { + throw new Error(`Unsupported encryption version: ${encrypted.v}`); + } + + try { + // Get device secret + const deviceSecret = await getDeviceSecret(); + + // Decode Base64 values + const iv = Buffer.from(encrypted.iv, 'base64'); + const salt = Buffer.from(encrypted.salt, 'base64'); + const ciphertext = Buffer.from(encrypted.ct, 'base64'); + const authTag = Buffer.from(encrypted.tag, 'base64'); + + // Validate lengths + if (iv.length !== IV_LENGTH) { + throw new Error(`Invalid IV length: ${iv.length}`); + } + if (salt.length !== SALT_LENGTH) { + throw new Error(`Invalid salt length: ${salt.length}`); + } + if (authTag.length !== AUTH_TAG_LENGTH) { + throw new Error(`Invalid auth tag length: ${authTag.length}`); + } + + // Derive key using the separate salt from payload + const key = await deriveKey(deviceSecret, salt); + + // Create decipher + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) as ReturnType & { + setAuthTag: (tag: Buffer) => void; + }; + decipher.setAuthTag(authTag); + + // Decrypt + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + // Parse JSON + const payload = JSON.parse(decrypted.toString('utf8')) as DecryptedPayload; + + return payload; + } catch (error) { + console.error('[Crypto] Decryption failed:', error); + throw new Error('Failed to decrypt push payload'); + } +} + +/** + * Encrypt data for testing purposes (client-side encryption) + * This is primarily used for development/testing. + * In production, encryption happens on the server. + */ +export async function encryptData( + data: Record +): Promise { + try { + const deviceSecret = await getDeviceSecret(); + + // Generate random IV and salt separately (NIST: salt should be at least 128 bits) + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + + // Derive key using proper random salt (wrap in Buffer for type compatibility) + const key = await deriveKey(deviceSecret, Buffer.from(salt)); + + // Create cipher + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) as ReturnType & { + getAuthTag: () => Buffer; + }; + + // Encrypt + const plaintext = Buffer.from(JSON.stringify(data), 'utf8'); + const encrypted = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + + // Get auth tag + const authTag = cipher.getAuthTag(); + + return { + v: 1, + iv: Buffer.from(iv).toString('base64'), + salt: Buffer.from(salt).toString('base64'), + ct: encrypted.toString('base64'), + tag: authTag.toString('base64'), + }; + } catch (error) { + console.error('[Crypto] Encryption failed:', error); + throw new Error('Failed to encrypt data'); + } +} + +/** + * Check if encryption is available on this device + */ +export function isEncryptionAvailable(): boolean { + try { + // Test if crypto functions are available + const testBytes = crypto.randomBytes(16); + return testBytes.length === 16; + } catch { + return false; + } +} + +/** + * Clear the device secret (on logout/unpair) + */ +export async function clearDeviceSecret(): Promise { + await SecureStore.deleteItemAsync(DEVICE_SECRET_KEY); + console.log('[Crypto] Cleared device secret'); +} diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts new file mode 100644 index 0000000..510d7c8 --- /dev/null +++ b/apps/mobile/src/lib/storage.ts @@ -0,0 +1,366 @@ +/** + * Secure storage utilities for mobile app credentials + * Supports multiple server connections with independent credentials + * + * Uses expo-secure-store for all storage (Expo Go compatible) + */ +import * as SecureStore from 'expo-secure-store'; + +// Keys for secure storage (per-server, uses serverId suffix) +const SECURE_KEYS = { + ACCESS_TOKEN: 'tracearr_access_token', + REFRESH_TOKEN: 'tracearr_refresh_token', +} as const; + +// Keys for general storage (JSON-serializable data, stored in SecureStore) +const STORAGE_KEYS = { + SERVERS: 'tracearr_servers', + ACTIVE_SERVER: 'tracearr_active_server', +} as const; + +/** + * Server connection info stored in SecureStore + */ +export interface ServerInfo { + id: string; // Unique identifier (from pairing response or generated) + url: string; + name: string; + type: 'plex' | 'jellyfin' | 'emby'; + addedAt: string; // ISO date string +} + +/** + * Credentials for a specific server (tokens stored in SecureStore) + */ +export interface ServerCredentials { + accessToken: string; + refreshToken: string; +} + +/** + * Full server data including credentials + */ +export interface StoredServer extends ServerInfo { + credentials: ServerCredentials; +} + +// Helper to get per-server secure key +function getSecureKey(baseKey: string, serverId: string): string { + return `${baseKey}_${serverId}`; +} + +export const storage = { + // ============================================================================ + // Server List Management + // ============================================================================ + + /** + * Get all connected servers + */ + async getServers(): Promise { + const data = await SecureStore.getItemAsync(STORAGE_KEYS.SERVERS); + if (!data) return []; + try { + return JSON.parse(data) as ServerInfo[]; + } catch { + return []; + } + }, + + /** + * Add a new server to the list + */ + async addServer(server: ServerInfo, credentials: ServerCredentials): Promise { + // Store credentials in SecureStore + await Promise.all([ + SecureStore.setItemAsync( + getSecureKey(SECURE_KEYS.ACCESS_TOKEN, server.id), + credentials.accessToken + ), + SecureStore.setItemAsync( + getSecureKey(SECURE_KEYS.REFRESH_TOKEN, server.id), + credentials.refreshToken + ), + ]); + + // Add server to list + const servers = await this.getServers(); + const existingIndex = servers.findIndex((s) => s.id === server.id); + if (existingIndex >= 0) { + servers[existingIndex] = server; + } else { + servers.push(server); + } + await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers)); + }, + + /** + * Remove a server and its credentials + */ + async removeServer(serverId: string): Promise { + // Read active server ID BEFORE making any changes to avoid race conditions + const activeId = await this.getActiveServerId(); + const servers = await this.getServers(); + const filtered = servers.filter((s) => s.id !== serverId); + + // Remove credentials from SecureStore + await Promise.all([ + SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)), + SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)), + ]); + + // Remove from server list + await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(filtered)); + + // Update active server if the removed one was active + if (activeId === serverId) { + // Select first remaining server or clear + if (filtered.length > 0) { + await this.setActiveServerId(filtered[0]!.id); + } else { + await SecureStore.deleteItemAsync(STORAGE_KEYS.ACTIVE_SERVER); + } + } + }, + + /** + * Get a specific server by ID + */ + async getServer(serverId: string): Promise { + const servers = await this.getServers(); + return servers.find((s) => s.id === serverId) ?? null; + }, + + /** + * Update server info (e.g., name changed) + */ + async updateServer(serverId: string, updates: Partial>): Promise { + const servers = await this.getServers(); + const index = servers.findIndex((s) => s.id === serverId); + if (index >= 0) { + servers[index] = { ...servers[index]!, ...updates }; + await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers)); + } + }, + + // ============================================================================ + // Active Server Selection + // ============================================================================ + + /** + * Get the currently active server ID + */ + async getActiveServerId(): Promise { + return SecureStore.getItemAsync(STORAGE_KEYS.ACTIVE_SERVER); + }, + + /** + * Set the active server + */ + async setActiveServerId(serverId: string): Promise { + await SecureStore.setItemAsync(STORAGE_KEYS.ACTIVE_SERVER, serverId); + }, + + /** + * Get the active server info + */ + async getActiveServer(): Promise { + const activeId = await this.getActiveServerId(); + if (!activeId) return null; + return this.getServer(activeId); + }, + + // ============================================================================ + // Credentials Management (per-server) + // ============================================================================ + + /** + * Get credentials for a specific server + */ + async getServerCredentials(serverId: string): Promise { + const [accessToken, refreshToken] = await Promise.all([ + SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)), + SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)), + ]); + + if (!accessToken || !refreshToken) { + return null; + } + + return { accessToken, refreshToken }; + }, + + /** + * Update tokens for a specific server (after refresh) + */ + async updateServerTokens( + serverId: string, + accessToken: string, + refreshToken: string + ): Promise { + await Promise.all([ + SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId), accessToken), + SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId), refreshToken), + ]); + }, + + /** + * Get access token for active server + */ + async getAccessToken(): Promise { + const activeId = await this.getActiveServerId(); + if (!activeId) return null; + return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, activeId)); + }, + + /** + * Get refresh token for active server + */ + async getRefreshToken(): Promise { + const activeId = await this.getActiveServerId(); + if (!activeId) return null; + return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, activeId)); + }, + + /** + * Get server URL for active server + */ + async getServerUrl(): Promise { + const server = await this.getActiveServer(); + return server?.url ?? null; + }, + + /** + * Update tokens for active server + */ + async updateTokens(accessToken: string, refreshToken: string): Promise { + const activeId = await this.getActiveServerId(); + if (!activeId) throw new Error('No active server'); + await this.updateServerTokens(activeId, accessToken, refreshToken); + }, + + // ============================================================================ + // Migration & Compatibility + // ============================================================================ + + /** + * Check if using legacy single-server storage and migrate if needed + */ + async migrateFromLegacy(): Promise { + // Check for legacy keys + const legacyUrl = await SecureStore.getItemAsync('tracearr_server_url'); + const legacyAccess = await SecureStore.getItemAsync('tracearr_access_token'); + const legacyRefresh = await SecureStore.getItemAsync('tracearr_refresh_token'); + const legacyName = await SecureStore.getItemAsync('tracearr_server_name'); + + if (legacyUrl && legacyAccess && legacyRefresh) { + // Generate a server ID from the URL + const serverId = Buffer.from(legacyUrl).toString('base64').slice(0, 16); + + const serverInfo: ServerInfo = { + id: serverId, + url: legacyUrl, + name: legacyName || 'Tracearr', + type: 'plex', // Assume plex for legacy, will update on next sync + addedAt: new Date().toISOString(), + }; + + // Add server with credentials + await this.addServer(serverInfo, { + accessToken: legacyAccess, + refreshToken: legacyRefresh, + }); + + // Set as active + await this.setActiveServerId(serverId); + + // Clean up legacy keys + await Promise.all([ + SecureStore.deleteItemAsync('tracearr_server_url'), + SecureStore.deleteItemAsync('tracearr_access_token'), + SecureStore.deleteItemAsync('tracearr_refresh_token'), + SecureStore.deleteItemAsync('tracearr_server_name'), + ]); + + return true; + } + + return false; + }, + + // ============================================================================ + // Legacy Compatibility (for existing code during transition) + // ============================================================================ + + /** + * @deprecated Use getServers() and getServerCredentials() instead + * Get stored credentials for active server (legacy compatibility) + */ + async getCredentials(): Promise<{ + serverUrl: string; + accessToken: string; + refreshToken: string; + serverName: string; + } | null> { + const server = await this.getActiveServer(); + if (!server) return null; + + const credentials = await this.getServerCredentials(server.id); + if (!credentials) return null; + + return { + serverUrl: server.url, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + serverName: server.name, + }; + }, + + /** + * @deprecated Use addServer() instead + * Store credentials (legacy compatibility - adds/updates single server) + */ + async storeCredentials(credentials: { + serverUrl: string; + accessToken: string; + refreshToken: string; + serverName: string; + }): Promise { + // Generate ID from URL for consistency + const serverId = Buffer.from(credentials.serverUrl).toString('base64').slice(0, 16); + + const serverInfo: ServerInfo = { + id: serverId, + url: credentials.serverUrl, + name: credentials.serverName, + type: 'plex', // Will be updated on server sync + addedAt: new Date().toISOString(), + }; + + await this.addServer(serverInfo, { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + }); + + await this.setActiveServerId(serverId); + }, + + /** + * @deprecated Use removeServer() for specific server + * Clear all credentials (legacy compatibility - removes active server) + */ + async clearCredentials(): Promise { + const activeId = await this.getActiveServerId(); + if (activeId) { + await this.removeServer(activeId); + } + }, + + /** + * Check if user is authenticated (has at least one server) + */ + async isAuthenticated(): Promise { + const servers = await this.getServers(); + return servers.length > 0; + }, +}; diff --git a/apps/mobile/src/lib/theme.ts b/apps/mobile/src/lib/theme.ts new file mode 100644 index 0000000..d6a0bea --- /dev/null +++ b/apps/mobile/src/lib/theme.ts @@ -0,0 +1,167 @@ +/** + * Design system tokens matching Tracearr web app + * These values match the web's dark mode theme exactly + */ + +export const colors = { + // Brand colors (from Tracearr web) + cyan: { + core: '#18D1E7', + deep: '#0EAFC8', + dark: '#0A7C96', + }, + blue: { + core: '#0B1A2E', + steel: '#162840', + soft: '#1E3A5C', + }, + + // Background colors - matching web dark mode + background: { + dark: '#050A12', + light: '#F9FAFB', + }, + card: { + dark: '#0B1A2E', + light: '#FFFFFF', + }, + surface: { + dark: '#0F2338', + light: '#F3F4F6', + }, + + // Accent colors + orange: { + core: '#F97316', + }, + purple: '#8B5CF6', + + // Status colors + success: '#22C55E', + warning: '#F59E0B', + error: '#EF4444', + danger: '#EF4444', + info: '#3B82F6', + + // Switch/toggle colors - matching web dark mode border + switch: { + trackOff: '#162840', + trackOn: '#0EAFC8', + thumbOn: '#18D1E7', + thumbOff: '#64748B', + }, + + // Text colors - matching web dark mode (CSS: --color-*) + text: { + // --color-foreground / --color-card-foreground + primary: { + dark: '#FFFFFF', + light: '#0B1A2E', + }, + // --color-muted-foreground (used for secondary/muted text) + secondary: { + dark: '#94A3B8', + light: '#64748B', + }, + // Alias for secondary - use this for muted text in inline styles + muted: { + dark: '#94A3B8', + light: '#64748B', + }, + }, + + // Icon colors - matching web dark mode (CSS: --color-icon-*) + icon: { + default: '#8CA3B8', + active: '#18D1E7', + danger: '#FF4C4C', + }, + + // Border colors - matching web dark mode (blue-steel) + border: { + dark: '#162840', + light: '#E5E7EB', + }, + + // Chart colors + chart: ['#18D1E7', '#0EAFC8', '#1E3A5C', '#F59E0B', '#EF4444', '#22C55E'], +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, +} as const; + +export const borderRadius = { + sm: 4, + md: 8, + lg: 12, + xl: 16, + full: 9999, +} as const; + +export const typography = { + fontFamily: { + regular: 'System', + medium: 'System', + bold: 'System', + }, + fontSize: { + xs: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + '2xl': 24, + '3xl': 30, + '4xl': 36, + }, + lineHeight: { + tight: 1.25, + normal: 1.5, + relaxed: 1.75, + }, +} as const; + +export const shadows = { + sm: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + md: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 6, + elevation: 3, + }, + lg: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.15, + shadowRadius: 15, + elevation: 5, + }, +} as const; + +// Helper function to get theme-aware colors +export function getThemeColor( + colorKey: 'background' | 'card' | 'surface' | 'border', + isDark: boolean +): string { + return colors[colorKey][isDark ? 'dark' : 'light']; +} + +export function getTextColor( + variant: 'primary' | 'secondary' | 'muted', + isDark: boolean +): string { + return colors.text[variant][isDark ? 'dark' : 'light']; +} diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/apps/mobile/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/mobile/src/providers/MediaServerProvider.tsx b/apps/mobile/src/providers/MediaServerProvider.tsx new file mode 100644 index 0000000..a7926b6 --- /dev/null +++ b/apps/mobile/src/providers/MediaServerProvider.tsx @@ -0,0 +1,153 @@ +/** + * Media Server selection provider + * Fetches available servers from Tracearr API and manages selection + * Similar to web's useServer hook + */ +import { + createContext, + useContext, + useState, + useCallback, + useMemo, + useEffect, + type ReactNode, +} from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import * as SecureStore from 'expo-secure-store'; +import type { Server } from '@tracearr/shared'; +import { api } from '../lib/api'; +import { useAuthStore } from '../lib/authStore'; + +const SELECTED_SERVER_KEY = 'tracearr_selected_media_server'; + +interface MediaServerContextValue { + servers: Server[]; + selectedServer: Server | null; + selectedServerId: string | null; + isLoading: boolean; + selectServer: (serverId: string | null) => void; + refetch: () => Promise; +} + +const MediaServerContext = createContext(null); + +export function MediaServerProvider({ children }: { children: ReactNode }) { + const { isAuthenticated, activeServerId: tracearrBackendId } = useAuthStore(); + const queryClient = useQueryClient(); + const [selectedServerId, setSelectedServerId] = useState(null); + const [initialized, setInitialized] = useState(false); + + // Load saved selection on mount + useEffect(() => { + void SecureStore.getItemAsync(SELECTED_SERVER_KEY).then((saved) => { + if (saved) { + setSelectedServerId(saved); + } + setInitialized(true); + }); + }, []); + + // Fetch available servers from API + const { + data: servers = [], + isLoading, + refetch, + } = useQuery({ + queryKey: ['media-servers', tracearrBackendId], + queryFn: () => api.servers.list(), + enabled: isAuthenticated && !!tracearrBackendId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + + // Validate selection when servers load + useEffect(() => { + if (!initialized || isLoading) return; + + if (servers.length === 0) { + if (selectedServerId) { + setSelectedServerId(null); + void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY); + } + return; + } + + // If selection is invalid (server no longer exists), select first + if (selectedServerId && !servers.some((s) => s.id === selectedServerId)) { + const firstServer = servers[0]; + if (firstServer) { + setSelectedServerId(firstServer.id); + void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id); + } + } + + // If no selection but servers exist, select first + if (!selectedServerId && servers.length > 0) { + const firstServer = servers[0]; + if (firstServer) { + setSelectedServerId(firstServer.id); + void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id); + } + } + }, [servers, selectedServerId, initialized, isLoading]); + + // Clear selection on logout + useEffect(() => { + if (!isAuthenticated) { + setSelectedServerId(null); + void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY); + } + }, [isAuthenticated]); + + const selectServer = useCallback( + (serverId: string | null) => { + setSelectedServerId(serverId); + if (serverId) { + void SecureStore.setItemAsync(SELECTED_SERVER_KEY, serverId); + } else { + void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY); + } + // Invalidate all server-dependent queries + void queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey[0]; + return key !== 'media-servers' && key !== 'servers'; + }, + }); + }, + [queryClient] + ); + + const selectedServer = useMemo(() => { + if (!selectedServerId) return null; + return servers.find((s) => s.id === selectedServerId) ?? null; + }, [servers, selectedServerId]); + + const value = useMemo( + () => ({ + servers, + selectedServer, + selectedServerId, + isLoading, + selectServer, + refetch, + }), + [servers, selectedServer, selectedServerId, isLoading, selectServer, refetch] + ); + + return ( + {children} + ); +} + +export function useMediaServer(): MediaServerContextValue { + const context = useContext(MediaServerContext); + if (!context) { + throw new Error('useMediaServer must be used within a MediaServerProvider'); + } + return context; +} + +export function useSelectedServerId(): string | null { + const { selectedServerId } = useMediaServer(); + return selectedServerId; +} diff --git a/apps/mobile/src/providers/QueryProvider.tsx b/apps/mobile/src/providers/QueryProvider.tsx new file mode 100644 index 0000000..4c08de9 --- /dev/null +++ b/apps/mobile/src/providers/QueryProvider.tsx @@ -0,0 +1,32 @@ +/** + * React Query provider for data fetching + */ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + retry: 2, + refetchOnWindowFocus: true, + }, + mutations: { + retry: 1, + }, + }, +}); + +interface QueryProviderProps { + children: React.ReactNode; +} + +export function QueryProvider({ children }: QueryProviderProps) { + return ( + + {children} + + ); +} + +export { queryClient }; diff --git a/apps/mobile/src/providers/SocketProvider.tsx b/apps/mobile/src/providers/SocketProvider.tsx new file mode 100644 index 0000000..06ca182 --- /dev/null +++ b/apps/mobile/src/providers/SocketProvider.tsx @@ -0,0 +1,160 @@ +/** + * Socket.io provider for real-time updates + * Connects to Tracearr backend and invalidates queries on events + */ +import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; +import { io } from 'socket.io-client'; +import type { Socket } from 'socket.io-client'; +import { AppState } from 'react-native'; +import type { AppStateStatus } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; +import { storage } from '../lib/storage'; +import { useAuthStore } from '../lib/authStore'; +import type { + ServerToClientEvents, + ClientToServerEvents, + ActiveSession, + ViolationWithDetails, + DashboardStats, +} from '@tracearr/shared'; + +interface SocketContextValue { + socket: Socket | null; + isConnected: boolean; +} + +const SocketContext = createContext({ + socket: null, + isConnected: false, +}); + +export function useSocket() { + const context = useContext(SocketContext); + if (!context) { + throw new Error('useSocket must be used within a SocketProvider'); + } + return context; +} + +export function SocketProvider({ children }: { children: React.ReactNode }) { + const { isAuthenticated, activeServerId, serverUrl } = useAuthStore(); + const queryClient = useQueryClient(); + const socketRef = useRef | null>(null); + const [isConnected, setIsConnected] = useState(false); + // Track which Tracearr backend we're connected to + const connectedServerIdRef = useRef(null); + + const connectSocket = useCallback(async () => { + if (!isAuthenticated || !serverUrl || !activeServerId) return; + + // If already connected to this backend, skip + if (connectedServerIdRef.current === activeServerId && socketRef.current?.connected) { + return; + } + + // Disconnect existing socket if connected to different backend + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + } + + const credentials = await storage.getServerCredentials(activeServerId); + if (!credentials) return; + + connectedServerIdRef.current = activeServerId; + + const newSocket: Socket = io(serverUrl, { + auth: { token: credentials.accessToken }, + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + }); + + newSocket.on('connect', () => { + console.log('Socket connected'); + setIsConnected(true); + // Subscribe to session updates + newSocket.emit('subscribe:sessions'); + }); + + newSocket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason); + setIsConnected(false); + }); + + newSocket.on('connect_error', (error) => { + console.error('Socket connection error:', error.message); + setIsConnected(false); + }); + + // Handle real-time events + // Use partial query keys to invalidate ALL cached data regardless of selected media server + // This matches the web app pattern where socket events invalidate all server-filtered caches + newSocket.on('session:started', (_session: ActiveSession) => { + // Invalidate all active sessions caches (any server filter) + void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] }); + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] }); + }); + + newSocket.on('session:stopped', (_sessionId: string) => { + void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] }); + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] }); + }); + + newSocket.on('session:updated', (_session: ActiveSession) => { + void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] }); + }); + + newSocket.on('violation:new', (_violation: ViolationWithDetails) => { + void queryClient.invalidateQueries({ queryKey: ['violations'] }); + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] }); + }); + + newSocket.on('stats:updated', (_stats: DashboardStats) => { + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] }); + }); + + socketRef.current = newSocket; + }, [isAuthenticated, serverUrl, activeServerId, queryClient]); + + // Connect/disconnect based on auth state + useEffect(() => { + if (isAuthenticated && serverUrl && activeServerId) { + void connectSocket(); + } else if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + connectedServerIdRef.current = null; + setIsConnected(false); + } + + return () => { + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + connectedServerIdRef.current = null; + } + }; + }, [isAuthenticated, serverUrl, activeServerId, connectSocket]); + + // Handle app state changes (background/foreground) + useEffect(() => { + const handleAppStateChange = (nextState: AppStateStatus) => { + if (nextState === 'active' && isAuthenticated && !isConnected) { + // Reconnect when app comes to foreground + void connectSocket(); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => subscription.remove(); + }, [isAuthenticated, isConnected, connectSocket]); + + return ( + + {children} + + ); +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..a1ad81e --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ], + "@tracearr/shared": [ + "../../packages/shared/src" + ] + }, + "types": [ + "expo-router/types", + "node" + ], + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "nativewind-env.d.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..09f0487 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'drizzle-kit'; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required'); +} + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL, + }, + verbose: true, + strict: true, +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..a02d624 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tracearr/server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch --env-file=../../.env src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "test": "vitest run", + "test:all": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --config vitest.unit.config.ts", + "test:services": "vitest run --config vitest.services.config.ts", + "test:routes": "vitest run --config vitest.routes.config.ts", + "test:security": "vitest run --config vitest.security.config.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:coverage": "vitest run --coverage", + "test:unit:coverage": "vitest run --config vitest.unit.config.ts --coverage", + "test:services:coverage": "vitest run --config vitest.services.config.ts --coverage", + "test:routes:coverage": "vitest run --config vitest.routes.config.ts --coverage", + "clean": "rm -rf dist .turbo coverage", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/cors": "^11.0.0", + "@fastify/helmet": "^13.0.0", + "@fastify/jwt": "^10.0.0", + "@fastify/rate-limit": "^10.0.0", + "@fastify/sensible": "^6.0.0", + "@fastify/static": "^8.0.0", + "@fastify/swagger": "^9.0.0", + "@fastify/swagger-ui": "^5.0.0", + "@fastify/websocket": "^11.0.0", + "@tracearr/shared": "workspace:*", + "bcrypt": "^6.0.0", + "bullmq": "^5.65.1", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.44.0", + "eventsource": "^4.1.0", + "expo-server-sdk": "^4.0.0", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", + "ioredis": "^5.4.0", + "jsonwebtoken": "^9.0.3", + "maxmind": "^4.3.29", + "pg": "^8.13.0", + "sharp": "^0.34.0", + "socket.io": "^4.8.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tracearr/test-utils": "workspace:*", + "@types/bcrypt": "^5.0.2", + "@types/eventsource": "^3.0.0", + "@types/jsonwebtoken": "^9.0.9", + "@types/pg": "^8.11.10", + "@vitest/coverage-v8": "^4.0.0", + "drizzle-kit": "^0.31.0", + "pino-pretty": "^13.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + } +} diff --git a/apps/server/src/db/client.ts b/apps/server/src/db/client.ts new file mode 100644 index 0000000..d1c76ca --- /dev/null +++ b/apps/server/src/db/client.ts @@ -0,0 +1,50 @@ +/** + * Database client and connection pool + */ + +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import pg from 'pg'; +import * as schema from './schema.js'; + +const { Pool } = pg; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required'); +} + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, // Maximum connections + idleTimeoutMillis: 20000, // Close idle connections after 20s + connectionTimeoutMillis: 10000, // Connection timeout (increased for complex queries) + maxUses: 7500, // Max queries per connection before refresh (prevents memory leaks) + allowExitOnIdle: false, // Keep pool alive during idle periods +}); + +// Log pool errors for debugging +pool.on('error', (err) => { + console.error('[DB Pool Error]', err.message); +}); + +export const db = drizzle(pool, { schema }); + +export async function closeDatabase(): Promise { + await pool.end(); +} + +export async function checkDatabaseConnection(): Promise { + try { + const client = await pool.connect(); + await client.query('SELECT 1'); + client.release(); + return true; + } catch (error) { + console.error('Database connection check failed:', error); + return false; + } +} + +export async function runMigrations(migrationsFolder: string): Promise { + await migrate(db, { migrationsFolder }); +} diff --git a/apps/server/src/db/migrations/0000_lying_dorian_gray.sql b/apps/server/src/db/migrations/0000_lying_dorian_gray.sql new file mode 100644 index 0000000..c896b5e --- /dev/null +++ b/apps/server/src/db/migrations/0000_lying_dorian_gray.sql @@ -0,0 +1,154 @@ +CREATE TABLE "rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(100) NOT NULL, + "type" varchar(50) NOT NULL, + "params" jsonb NOT NULL, + "server_user_id" uuid, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "server_users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "server_id" uuid NOT NULL, + "external_id" varchar(255) NOT NULL, + "username" varchar(255) NOT NULL, + "email" varchar(255), + "thumb_url" text, + "is_server_admin" boolean DEFAULT false NOT NULL, + "trust_score" integer DEFAULT 100 NOT NULL, + "session_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "servers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(100) NOT NULL, + "type" varchar(20) NOT NULL, + "url" text NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "server_id" uuid NOT NULL, + "server_user_id" uuid NOT NULL, + "session_key" varchar(255) NOT NULL, + "state" varchar(20) NOT NULL, + "media_type" varchar(20) NOT NULL, + "media_title" text NOT NULL, + "grandparent_title" varchar(500), + "season_number" integer, + "episode_number" integer, + "year" integer, + "thumb_path" varchar(500), + "rating_key" varchar(255), + "external_session_id" varchar(255), + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "stopped_at" timestamp with time zone, + "duration_ms" integer, + "total_duration_ms" integer, + "progress_ms" integer, + "last_paused_at" timestamp with time zone, + "paused_duration_ms" integer DEFAULT 0 NOT NULL, + "reference_id" uuid, + "watched" boolean DEFAULT false NOT NULL, + "ip_address" varchar(45) NOT NULL, + "geo_city" varchar(255), + "geo_region" varchar(255), + "geo_country" varchar(100), + "geo_lat" real, + "geo_lon" real, + "player_name" varchar(255), + "device_id" varchar(255), + "product" varchar(255), + "device" varchar(255), + "platform" varchar(100), + "quality" varchar(100), + "is_transcode" boolean DEFAULT false NOT NULL, + "bitrate" integer +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "id" integer PRIMARY KEY DEFAULT 1 NOT NULL, + "allow_guest_access" boolean DEFAULT false NOT NULL, + "discord_webhook_url" text, + "custom_webhook_url" text, + "notify_on_violation" boolean DEFAULT true NOT NULL, + "notify_on_session_start" boolean DEFAULT false NOT NULL, + "notify_on_session_stop" boolean DEFAULT false NOT NULL, + "notify_on_server_down" boolean DEFAULT true NOT NULL, + "poller_enabled" boolean DEFAULT true NOT NULL, + "poller_interval_ms" integer DEFAULT 15000 NOT NULL, + "tautulli_url" text, + "tautulli_api_key" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" varchar(100) NOT NULL, + "name" varchar(255), + "thumbnail" text, + "email" varchar(255), + "password_hash" text, + "plex_account_id" varchar(255), + "role" varchar(20) DEFAULT 'member' NOT NULL, + "aggregate_trust_score" integer DEFAULT 100 NOT NULL, + "total_violations" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "violations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "rule_id" uuid NOT NULL, + "server_user_id" uuid NOT NULL, + "session_id" uuid NOT NULL, + "severity" varchar(20) NOT NULL, + "data" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "acknowledged_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "rules" ADD CONSTRAINT "rules_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_users" ADD CONSTRAINT "server_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "server_users" ADD CONSTRAINT "server_users_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "violations" ADD CONSTRAINT "violations_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "violations" ADD CONSTRAINT "violations_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "violations" ADD CONSTRAINT "violations_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "rules_active_idx" ON "rules" USING btree ("is_active");--> statement-breakpoint +CREATE INDEX "rules_server_user_id_idx" ON "rules" USING btree ("server_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "server_users_user_server_unique" ON "server_users" USING btree ("user_id","server_id");--> statement-breakpoint +CREATE UNIQUE INDEX "server_users_server_external_unique" ON "server_users" USING btree ("server_id","external_id");--> statement-breakpoint +CREATE INDEX "server_users_user_idx" ON "server_users" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "server_users_server_idx" ON "server_users" USING btree ("server_id");--> statement-breakpoint +CREATE INDEX "server_users_username_idx" ON "server_users" USING btree ("username");--> statement-breakpoint +CREATE INDEX "sessions_server_user_time_idx" ON "sessions" USING btree ("server_user_id","started_at");--> statement-breakpoint +CREATE INDEX "sessions_server_time_idx" ON "sessions" USING btree ("server_id","started_at");--> statement-breakpoint +CREATE INDEX "sessions_state_idx" ON "sessions" USING btree ("state");--> statement-breakpoint +CREATE INDEX "sessions_external_session_idx" ON "sessions" USING btree ("server_id","external_session_id");--> statement-breakpoint +CREATE INDEX "sessions_device_idx" ON "sessions" USING btree ("server_user_id","device_id");--> statement-breakpoint +CREATE INDEX "sessions_reference_idx" ON "sessions" USING btree ("reference_id");--> statement-breakpoint +CREATE INDEX "sessions_server_user_rating_idx" ON "sessions" USING btree ("server_user_id","rating_key");--> statement-breakpoint +CREATE INDEX "sessions_geo_idx" ON "sessions" USING btree ("geo_lat","geo_lon");--> statement-breakpoint +CREATE INDEX "sessions_geo_time_idx" ON "sessions" USING btree ("started_at","geo_lat","geo_lon");--> statement-breakpoint +CREATE INDEX "sessions_media_type_idx" ON "sessions" USING btree ("media_type");--> statement-breakpoint +CREATE INDEX "sessions_transcode_idx" ON "sessions" USING btree ("is_transcode");--> statement-breakpoint +CREATE INDEX "sessions_platform_idx" ON "sessions" USING btree ("platform");--> statement-breakpoint +CREATE INDEX "sessions_top_movies_idx" ON "sessions" USING btree ("media_type","media_title","year");--> statement-breakpoint +CREATE INDEX "sessions_top_shows_idx" ON "sessions" USING btree ("media_type","grandparent_title");--> statement-breakpoint +CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint +CREATE INDEX "users_plex_account_id_idx" ON "users" USING btree ("plex_account_id");--> statement-breakpoint +CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint +CREATE INDEX "violations_server_user_id_idx" ON "violations" USING btree ("server_user_id");--> statement-breakpoint +CREATE INDEX "violations_rule_id_idx" ON "violations" USING btree ("rule_id");--> statement-breakpoint +CREATE INDEX "violations_created_at_idx" ON "violations" USING btree ("created_at"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0001_graceful_starjammers.sql b/apps/server/src/db/migrations/0001_graceful_starjammers.sql new file mode 100644 index 0000000..9ae8faa --- /dev/null +++ b/apps/server/src/db/migrations/0001_graceful_starjammers.sql @@ -0,0 +1,2 @@ +DROP INDEX "users_username_unique";--> statement-breakpoint +CREATE INDEX "users_username_idx" ON "users" USING btree ("username"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0002_rainy_bishop.sql b/apps/server/src/db/migrations/0002_rainy_bishop.sql new file mode 100644 index 0000000..05e99e8 --- /dev/null +++ b/apps/server/src/db/migrations/0002_rainy_bishop.sql @@ -0,0 +1,26 @@ +CREATE TABLE "mobile_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "refresh_token_hash" varchar(64) NOT NULL, + "device_name" varchar(100) NOT NULL, + "device_id" varchar(100) NOT NULL, + "platform" varchar(20) NOT NULL, + "expo_push_token" varchar(255), + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "mobile_sessions_refresh_token_hash_unique" UNIQUE("refresh_token_hash") +); +--> statement-breakpoint +CREATE TABLE "mobile_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token_hash" varchar(64) NOT NULL, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "rotated_at" timestamp with time zone, + CONSTRAINT "mobile_tokens_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "external_url" text;--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "base_path" varchar(100) DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "trust_proxy" boolean DEFAULT false NOT NULL;--> statement-breakpoint +CREATE INDEX "mobile_sessions_device_id_idx" ON "mobile_sessions" USING btree ("device_id");--> statement-breakpoint +CREATE INDEX "mobile_sessions_refresh_token_idx" ON "mobile_sessions" USING btree ("refresh_token_hash"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0003_black_maginty.sql b/apps/server/src/db/migrations/0003_black_maginty.sql new file mode 100644 index 0000000..d923dd3 --- /dev/null +++ b/apps/server/src/db/migrations/0003_black_maginty.sql @@ -0,0 +1,28 @@ +CREATE TABLE "notification_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mobile_session_id" uuid NOT NULL, + "push_enabled" boolean DEFAULT true NOT NULL, + "on_violation_detected" boolean DEFAULT true NOT NULL, + "on_stream_started" boolean DEFAULT false NOT NULL, + "on_stream_stopped" boolean DEFAULT false NOT NULL, + "on_concurrent_streams" boolean DEFAULT true NOT NULL, + "on_new_device" boolean DEFAULT true NOT NULL, + "on_trust_score_changed" boolean DEFAULT false NOT NULL, + "on_server_down" boolean DEFAULT true NOT NULL, + "on_server_up" boolean DEFAULT true NOT NULL, + "violation_min_severity" integer DEFAULT 1 NOT NULL, + "violation_rule_types" text[] DEFAULT '{}', + "max_per_minute" integer DEFAULT 10 NOT NULL, + "max_per_hour" integer DEFAULT 60 NOT NULL, + "quiet_hours_enabled" boolean DEFAULT false NOT NULL, + "quiet_hours_start" varchar(5), + "quiet_hours_end" varchar(5), + "quiet_hours_timezone" varchar(50) DEFAULT 'UTC', + "quiet_hours_override_critical" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_preferences_mobile_session_id_unique" UNIQUE("mobile_session_id") +); +--> statement-breakpoint +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_mobile_session_id_mobile_sessions_id_fk" FOREIGN KEY ("mobile_session_id") REFERENCES "public"."mobile_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "notification_prefs_mobile_session_idx" ON "notification_preferences" USING btree ("mobile_session_id"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0004_bent_unus.sql b/apps/server/src/db/migrations/0004_bent_unus.sql new file mode 100644 index 0000000..02ae3e8 --- /dev/null +++ b/apps/server/src/db/migrations/0004_bent_unus.sql @@ -0,0 +1,25 @@ +CREATE TABLE "notification_channel_routing" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_type" varchar(50) NOT NULL, + "discord_enabled" boolean DEFAULT true NOT NULL, + "webhook_enabled" boolean DEFAULT true NOT NULL, + "push_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_channel_routing_event_type_unique" UNIQUE("event_type") +); +--> statement-breakpoint +CREATE INDEX "notification_channel_routing_event_type_idx" ON "notification_channel_routing" USING btree ("event_type"); +--> statement-breakpoint +-- Seed default routing configuration for all event types +INSERT INTO "notification_channel_routing" ("event_type", "discord_enabled", "webhook_enabled", "push_enabled") +VALUES + ('violation_detected', true, true, true), + ('stream_started', false, false, false), + ('stream_stopped', false, false, false), + ('concurrent_streams', true, true, true), + ('new_device', true, true, true), + ('trust_score_changed', false, false, false), + ('server_down', true, true, true), + ('server_up', true, true, true) +ON CONFLICT ("event_type") DO NOTHING; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0005_elite_wendell_vaughn.sql b/apps/server/src/db/migrations/0005_elite_wendell_vaughn.sql new file mode 100644 index 0000000..f20ebec --- /dev/null +++ b/apps/server/src/db/migrations/0005_elite_wendell_vaughn.sql @@ -0,0 +1 @@ +ALTER TABLE "mobile_sessions" ADD COLUMN "device_secret" varchar(64); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0006_worthless_blue_shield.sql b/apps/server/src/db/migrations/0006_worthless_blue_shield.sql new file mode 100644 index 0000000..4d1c94c --- /dev/null +++ b/apps/server/src/db/migrations/0006_worthless_blue_shield.sql @@ -0,0 +1,3 @@ +CREATE INDEX "mobile_sessions_expo_push_token_idx" ON "mobile_sessions" USING btree ("expo_push_token");--> statement-breakpoint +ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_start_format" CHECK ("notification_preferences"."quiet_hours_start" IS NULL OR "notification_preferences"."quiet_hours_start" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$');--> statement-breakpoint +ALTER TABLE "notification_preferences" ADD CONSTRAINT "quiet_hours_end_format" CHECK ("notification_preferences"."quiet_hours_end" IS NULL OR "notification_preferences"."quiet_hours_end" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0007_tense_pestilence.sql b/apps/server/src/db/migrations/0007_tense_pestilence.sql new file mode 100644 index 0000000..6db227e --- /dev/null +++ b/apps/server/src/db/migrations/0007_tense_pestilence.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "mobile_enabled" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0008_update_mobile_tokens_schema.sql b/apps/server/src/db/migrations/0008_update_mobile_tokens_schema.sql new file mode 100644 index 0000000..a9d6f13 --- /dev/null +++ b/apps/server/src/db/migrations/0008_update_mobile_tokens_schema.sql @@ -0,0 +1,41 @@ +-- Custom SQL migration file, put your code below! -- + +-- Update mobile_tokens schema for one-time pairing tokens +-- Remove old columns (is_enabled, rotated_at) and add new columns (expires_at, created_by, used_at) + +-- Step 1: Clear existing tokens (breaking change - old schema incompatible) +DELETE FROM "mobile_tokens"; + +-- Step 2: Drop old columns +ALTER TABLE "mobile_tokens" DROP COLUMN IF EXISTS "is_enabled"; +ALTER TABLE "mobile_tokens" DROP COLUMN IF EXISTS "rotated_at"; + +-- Step 3: Add new required column with temporary default (IF NOT EXISTS for idempotency) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'expires_at') THEN + ALTER TABLE "mobile_tokens" ADD COLUMN "expires_at" timestamp with time zone NOT NULL DEFAULT NOW() + INTERVAL '15 minutes'; + END IF; +END $$; + +-- Step 4: Add nullable columns (IF NOT EXISTS for idempotency) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'created_by') THEN + ALTER TABLE "mobile_tokens" ADD COLUMN "created_by" uuid; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'mobile_tokens' AND column_name = 'used_at') THEN + ALTER TABLE "mobile_tokens" ADD COLUMN "used_at" timestamp with time zone; + END IF; +END $$; + +-- Step 5: Add foreign key constraint (IF NOT EXISTS for idempotency) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'mobile_tokens_created_by_users_id_fk') THEN + ALTER TABLE "mobile_tokens" ADD CONSTRAINT "mobile_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + END IF; +END $$; + +-- Step 6: Remove temporary default from expires_at +ALTER TABLE "mobile_tokens" ALTER COLUMN "expires_at" DROP DEFAULT; diff --git a/apps/server/src/db/migrations/0009_quiet_vertigo.sql b/apps/server/src/db/migrations/0009_quiet_vertigo.sql new file mode 100644 index 0000000..c866487 --- /dev/null +++ b/apps/server/src/db/migrations/0009_quiet_vertigo.sql @@ -0,0 +1,20 @@ +-- TimescaleDB hypertables with columnstore don't allow non-constant defaults like now() +-- So we add columns as nullable first, backfill, then set NOT NULL + +-- Add last_seen_at as nullable first +ALTER TABLE "sessions" ADD COLUMN "last_seen_at" timestamp with time zone;--> statement-breakpoint + +-- Backfill existing rows: use started_at as the initial last_seen_at value +UPDATE "sessions" SET "last_seen_at" = "started_at" WHERE "last_seen_at" IS NULL;--> statement-breakpoint + +-- Now set NOT NULL constraint (no default needed - app always provides value) +ALTER TABLE "sessions" ALTER COLUMN "last_seen_at" SET NOT NULL;--> statement-breakpoint + +-- Add force_stopped column +ALTER TABLE "sessions" ADD COLUMN "force_stopped" boolean DEFAULT false NOT NULL;--> statement-breakpoint + +-- Add short_session column +ALTER TABLE "sessions" ADD COLUMN "short_session" boolean DEFAULT false NOT NULL;--> statement-breakpoint + +-- Create index for stale session detection +CREATE INDEX "sessions_stale_detection_idx" ON "sessions" USING btree ("last_seen_at","stopped_at"); diff --git a/apps/server/src/db/migrations/0010_fair_zuras.sql b/apps/server/src/db/migrations/0010_fair_zuras.sql new file mode 100644 index 0000000..fe4d99c --- /dev/null +++ b/apps/server/src/db/migrations/0010_fair_zuras.sql @@ -0,0 +1,18 @@ +-- Multi-server support: Add user_id to mobile_sessions +-- BREAKING CHANGE: Clears existing mobile sessions - users must re-pair devices + +-- Clear existing data (notification_preferences has FK to mobile_sessions) +DELETE FROM "notification_preferences";--> statement-breakpoint +DELETE FROM "mobile_sessions";--> statement-breakpoint + +-- Unrelated schema drift fix from drizzle-kit +ALTER TABLE "sessions" ALTER COLUMN "last_seen_at" DROP DEFAULT;--> statement-breakpoint + +-- Add user_id column (required for multi-user mobile support) +ALTER TABLE "mobile_sessions" ADD COLUMN "user_id" uuid NOT NULL;--> statement-breakpoint + +-- Add foreign key constraint +ALTER TABLE "mobile_sessions" ADD CONSTRAINT "mobile_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint + +-- Add index for efficient user lookups +CREATE INDEX "mobile_sessions_user_idx" ON "mobile_sessions" USING btree ("user_id"); diff --git a/apps/server/src/db/migrations/0011_breezy_ultron.sql b/apps/server/src/db/migrations/0011_breezy_ultron.sql new file mode 100644 index 0000000..e8108a7 --- /dev/null +++ b/apps/server/src/db/migrations/0011_breezy_ultron.sql @@ -0,0 +1,27 @@ +-- Note: session_id has no FK constraint because sessions is a TimescaleDB hypertable +-- (hypertables don't support foreign key references to their primary key) +CREATE TABLE "termination_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" uuid NOT NULL, + "server_id" uuid NOT NULL, + "server_user_id" uuid NOT NULL, + "trigger" varchar(20) NOT NULL, + "triggered_by_user_id" uuid, + "rule_id" uuid, + "violation_id" uuid, + "reason" text, + "success" boolean NOT NULL, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_server_user_id_server_users_id_fk" FOREIGN KEY ("server_user_id") REFERENCES "public"."server_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_triggered_by_user_id_users_id_fk" FOREIGN KEY ("triggered_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_rule_id_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."rules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termination_logs" ADD CONSTRAINT "termination_logs_violation_id_violations_id_fk" FOREIGN KEY ("violation_id") REFERENCES "public"."violations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "termination_logs_session_idx" ON "termination_logs" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "termination_logs_server_user_idx" ON "termination_logs" USING btree ("server_user_id");--> statement-breakpoint +CREATE INDEX "termination_logs_triggered_by_idx" ON "termination_logs" USING btree ("triggered_by_user_id");--> statement-breakpoint +CREATE INDEX "termination_logs_rule_idx" ON "termination_logs" USING btree ("rule_id");--> statement-breakpoint +CREATE INDEX "termination_logs_created_at_idx" ON "termination_logs" USING btree ("created_at"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0012_strong_hannibal_king.sql b/apps/server/src/db/migrations/0012_strong_hannibal_king.sql new file mode 100644 index 0000000..a329b22 --- /dev/null +++ b/apps/server/src/db/migrations/0012_strong_hannibal_king.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" ADD COLUMN "plex_session_id" varchar(255); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0013_same_rage.sql b/apps/server/src/db/migrations/0013_same_rage.sql new file mode 100644 index 0000000..64f49d9 --- /dev/null +++ b/apps/server/src/db/migrations/0013_same_rage.sql @@ -0,0 +1,3 @@ +ALTER TABLE "termination_logs" DROP CONSTRAINT IF EXISTS "termination_logs_session_id_sessions_id_fk"; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "sessions_dedup_fallback_idx" ON "sessions" USING btree ("server_id","server_user_id","rating_key","started_at"); diff --git a/apps/server/src/db/migrations/0014_past_molly_hayes.sql b/apps/server/src/db/migrations/0014_past_molly_hayes.sql new file mode 100644 index 0000000..715fc50 --- /dev/null +++ b/apps/server/src/db/migrations/0014_past_molly_hayes.sql @@ -0,0 +1,2 @@ +ALTER TABLE "settings" ADD COLUMN "webhook_format" text;--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "ntfy_topic" text; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0015_gifted_the_liberteens.sql b/apps/server/src/db/migrations/0015_gifted_the_liberteens.sql new file mode 100644 index 0000000..9936fa8 --- /dev/null +++ b/apps/server/src/db/migrations/0015_gifted_the_liberteens.sql @@ -0,0 +1 @@ +ALTER TABLE "servers" ADD COLUMN "machine_identifier" varchar(100); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0016_yummy_enchantress.sql b/apps/server/src/db/migrations/0016_yummy_enchantress.sql new file mode 100644 index 0000000..87f975e --- /dev/null +++ b/apps/server/src/db/migrations/0016_yummy_enchantress.sql @@ -0,0 +1,4 @@ +ALTER TABLE "settings" DROP COLUMN "notify_on_violation";--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "notify_on_session_start";--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "notify_on_session_stop";--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "notify_on_server_down"; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0017_broken_husk.sql b/apps/server/src/db/migrations/0017_broken_husk.sql new file mode 100644 index 0000000..2001eee --- /dev/null +++ b/apps/server/src/db/migrations/0017_broken_husk.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "primary_auth_method" varchar(20) DEFAULT 'local' NOT NULL; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0018_robust_shotgun.sql b/apps/server/src/db/migrations/0018_robust_shotgun.sql new file mode 100644 index 0000000..c9fa562 --- /dev/null +++ b/apps/server/src/db/migrations/0018_robust_shotgun.sql @@ -0,0 +1,2 @@ +ALTER TABLE "notification_channel_routing" ADD COLUMN "web_toast_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "unit_system" varchar(20) DEFAULT 'metric' NOT NULL; \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0000_snapshot.json b/apps/server/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..0f5094f --- /dev/null +++ b/apps/server/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1338 @@ +{ + "id": "8b6be6d6-3eb4-4266-b5d9-8d67ba7f42c9", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0001_snapshot.json b/apps/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..16ad583 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1338 @@ +{ + "id": "88b17896-d430-4ffe-a00c-58dff181bc31", + "prevId": "8b6be6d6-3eb4-4266-b5d9-8d67ba7f42c9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0002_snapshot.json b/apps/server/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..1a3a4a7 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,1515 @@ +{ + "id": "d030aeff-fced-4c3c-9be4-744259c70942", + "prevId": "88b17896-d430-4ffe-a00c-58dff181bc31", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0003_snapshot.json b/apps/server/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..cb7d51c --- /dev/null +++ b/apps/server/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1717 @@ +{ + "id": "be029f85-afdd-430a-96a5-cccc31dad521", + "prevId": "d030aeff-fced-4c3c-9be4-744259c70942", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0004_snapshot.json b/apps/server/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..e2f106b --- /dev/null +++ b/apps/server/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1802 @@ +{ + "id": "3eb09195-fe18-46bc-8f4e-bafc4433406c", + "prevId": "be029f85-afdd-430a-96a5-cccc31dad521", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0005_snapshot.json b/apps/server/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..3e615a1 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1808 @@ +{ + "id": "08687bd0-3796-4d20-a2d6-48fd4cff8f02", + "prevId": "3eb09195-fe18-46bc-8f4e-bafc4433406c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0006_snapshot.json b/apps/server/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..9e75740 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1832 @@ +{ + "id": "581cc76e-a796-4824-81c7-a389b76e38ee", + "prevId": "08687bd0-3796-4d20-a2d6-48fd4cff8f02", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0007_snapshot.json b/apps/server/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..96bf70c --- /dev/null +++ b/apps/server/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1839 @@ +{ + "id": "77c3957e-5c47-4e05-9940-522acb5875da", + "prevId": "581cc76e-a796-4824-81c7-a389b76e38ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0008_snapshot.json b/apps/server/src/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..e5d5cdc --- /dev/null +++ b/apps/server/src/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1858 @@ +{ + "id": "c27661ad-cae2-4f0a-9493-02415e533b2a", + "prevId": "77c3957e-5c47-4e05-9940-522acb5875da", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0009_snapshot.json b/apps/server/src/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..957e700 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,1900 @@ +{ + "id": "c99cfde1-e5c7-4285-a532-0b5a2aa4b7e5", + "prevId": "c27661ad-cae2-4f0a-9493-02415e533b2a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0010_snapshot.json b/apps/server/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..b943adf --- /dev/null +++ b/apps/server/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,1934 @@ +{ + "id": "26659262-fa65-4bd6-b932-ba96f085e591", + "prevId": "c99cfde1-e5c7-4285-a532-0b5a2aa4b7e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0011_snapshot.json b/apps/server/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..0509de5 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,2176 @@ +{ + "id": "01f004dd-ac94-4bfa-b92c-7b00c3e75282", + "prevId": "26659262-fa65-4bd6-b932-ba96f085e591", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_session_id_sessions_id_fk": { + "name": "termination_logs_session_id_sessions_id_fk", + "tableFrom": "termination_logs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0012_snapshot.json b/apps/server/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..0bdccb2 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,2182 @@ +{ + "id": "c4c238b9-3c5a-4f04-86e8-7fe110dbf728", + "prevId": "01f004dd-ac94-4bfa-b92c-7b00c3e75282", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_session_id_sessions_id_fk": { + "name": "termination_logs_session_id_sessions_id_fk", + "tableFrom": "termination_logs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0013_snapshot.json b/apps/server/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..a5c894c --- /dev/null +++ b/apps/server/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,2202 @@ +{ + "id": "925d262c-46d1-40eb-85ee-7ffb0e168a3b", + "prevId": "c4c238b9-3c5a-4f04-86e8-7fe110dbf728", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0014_snapshot.json b/apps/server/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..4cff8e9 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,2214 @@ +{ + "id": "a9ca0e09-3a48-41a1-a185-678af54503a9", + "prevId": "925d262c-46d1-40eb-85ee-7ffb0e168a3b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_format": { + "name": "webhook_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0015_snapshot.json b/apps/server/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..131cf7e --- /dev/null +++ b/apps/server/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,2220 @@ +{ + "id": "c6b48fc8-700c-4486-baf7-4dcf6348f8f8", + "prevId": "a9ca0e09-3a48-41a1-a185-678af54503a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_identifier": { + "name": "machine_identifier", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_format": { + "name": "webhook_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_violation": { + "name": "notify_on_violation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_session_start": { + "name": "notify_on_session_start", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_session_stop": { + "name": "notify_on_session_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_server_down": { + "name": "notify_on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0016_snapshot.json b/apps/server/src/db/migrations/meta/0016_snapshot.json new file mode 100644 index 0000000..90f62df --- /dev/null +++ b/apps/server/src/db/migrations/meta/0016_snapshot.json @@ -0,0 +1,2192 @@ +{ + "id": "9f9c5ef9-2c35-4c31-be56-3211630bcdb2", + "prevId": "c6b48fc8-700c-4486-baf7-4dcf6348f8f8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_identifier": { + "name": "machine_identifier", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_format": { + "name": "webhook_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0017_snapshot.json b/apps/server/src/db/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..4240fe4 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0017_snapshot.json @@ -0,0 +1,2199 @@ +{ + "id": "60ebf6cd-93a8-469f-a2a4-5219bca7a982", + "prevId": "557d36e4-13aa-427e-8450-1da83382a0fe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_identifier": { + "name": "machine_identifier", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_format": { + "name": "webhook_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "primary_auth_method": { + "name": "primary_auth_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0018_snapshot.json b/apps/server/src/db/migrations/meta/0018_snapshot.json new file mode 100644 index 0000000..b4eb78d --- /dev/null +++ b/apps/server/src/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,2213 @@ +{ + "id": "c23f3a9f-5926-4795-9ab9-c9e633d2cf2d", + "prevId": "60ebf6cd-93a8-469f-a2a4-5219bca7a982", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.mobile_sessions": { + "name": "mobile_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "expo_push_token": { + "name": "expo_push_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_secret": { + "name": "device_secret", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_sessions_user_idx": { + "name": "mobile_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_device_id_idx": { + "name": "mobile_sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_refresh_token_idx": { + "name": "mobile_sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_sessions_expo_push_token_idx": { + "name": "mobile_sessions_expo_push_token_idx", + "columns": [ + { + "expression": "expo_push_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_sessions_user_id_users_id_fk": { + "name": "mobile_sessions_user_id_users_id_fk", + "tableFrom": "mobile_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_sessions_refresh_token_hash_unique": { + "name": "mobile_sessions_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_tokens": { + "name": "mobile_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mobile_tokens_created_by_users_id_fk": { + "name": "mobile_tokens_created_by_users_id_fk", + "tableFrom": "mobile_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_tokens_token_hash_unique": { + "name": "mobile_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel_routing": { + "name": "notification_channel_routing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "discord_enabled": { + "name": "discord_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "web_toast_enabled": { + "name": "web_toast_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_channel_routing_event_type_idx": { + "name": "notification_channel_routing_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_channel_routing_event_type_unique": { + "name": "notification_channel_routing_event_type_unique", + "nullsNotDistinct": false, + "columns": [ + "event_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mobile_session_id": { + "name": "mobile_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_violation_detected": { + "name": "on_violation_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_stream_started": { + "name": "on_stream_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_stream_stopped": { + "name": "on_stream_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_concurrent_streams": { + "name": "on_concurrent_streams", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_new_device": { + "name": "on_new_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_trust_score_changed": { + "name": "on_trust_score_changed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "on_server_down": { + "name": "on_server_down", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "on_server_up": { + "name": "on_server_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "violation_min_severity": { + "name": "violation_min_severity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "violation_rule_types": { + "name": "violation_rule_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "max_per_minute": { + "name": "max_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "quiet_hours_enabled": { + "name": "quiet_hours_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_timezone": { + "name": "quiet_hours_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "quiet_hours_override_critical": { + "name": "quiet_hours_override_critical", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_prefs_mobile_session_idx": { + "name": "notification_prefs_mobile_session_idx", + "columns": [ + { + "expression": "mobile_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preferences_mobile_session_id_mobile_sessions_id_fk": { + "name": "notification_preferences_mobile_session_id_mobile_sessions_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "mobile_sessions", + "columnsFrom": [ + "mobile_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_mobile_session_id_unique": { + "name": "notification_preferences_mobile_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "mobile_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "quiet_hours_start_format": { + "name": "quiet_hours_start_format", + "value": "\"notification_preferences\".\"quiet_hours_start\" IS NULL OR \"notification_preferences\".\"quiet_hours_start\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + }, + "quiet_hours_end_format": { + "name": "quiet_hours_end_format", + "value": "\"notification_preferences\".\"quiet_hours_end\" IS NULL OR \"notification_preferences\".\"quiet_hours_end\" ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'" + } + }, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rules_active_idx": { + "name": "rules_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rules_server_user_id_idx": { + "name": "rules_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_server_user_id_server_users_id_fk": { + "name": "rules_server_user_id_server_users_id_fk", + "tableFrom": "rules", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_users": { + "name": "server_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_server_admin": { + "name": "is_server_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trust_score": { + "name": "trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "session_count": { + "name": "session_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "server_users_user_server_unique": { + "name": "server_users_user_server_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_external_unique": { + "name": "server_users_server_external_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_user_idx": { + "name": "server_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_server_idx": { + "name": "server_users_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "server_users_username_idx": { + "name": "server_users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "server_users_user_id_users_id_fk": { + "name": "server_users_user_id_users_id_fk", + "tableFrom": "server_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "server_users_server_id_servers_id_fk": { + "name": "server_users_server_id_servers_id_fk", + "tableFrom": "server_users", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_identifier": { + "name": "machine_identifier", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_key": { + "name": "session_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plex_session_id": { + "name": "plex_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "media_title": { + "name": "media_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grandparent_title": { + "name": "grandparent_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumb_path": { + "name": "thumb_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "rating_key": { + "name": "rating_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "external_session_id": { + "name": "external_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "progress_ms": { + "name": "progress_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_paused_at": { + "name": "last_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paused_duration_ms": { + "name": "paused_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "watched": { + "name": "watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_stopped": { + "name": "force_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "short_session": { + "name": "short_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "geo_city": { + "name": "geo_city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_region": { + "name": "geo_region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "geo_country": { + "name": "geo_country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "geo_lat": { + "name": "geo_lat", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "geo_lon": { + "name": "geo_lon", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "player_name": { + "name": "player_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "product": { + "name": "product", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device": { + "name": "device", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_transcode": { + "name": "is_transcode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bitrate": { + "name": "bitrate", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_server_user_time_idx": { + "name": "sessions_server_user_time_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_time_idx": { + "name": "sessions_server_time_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_state_idx": { + "name": "sessions_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_external_session_idx": { + "name": "sessions_external_session_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_idx": { + "name": "sessions_device_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_reference_idx": { + "name": "sessions_reference_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_user_rating_idx": { + "name": "sessions_server_user_rating_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_dedup_fallback_idx": { + "name": "sessions_dedup_fallback_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rating_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_idx": { + "name": "sessions_geo_idx", + "columns": [ + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_geo_time_idx": { + "name": "sessions_geo_time_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "geo_lon", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_media_type_idx": { + "name": "sessions_media_type_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_transcode_idx": { + "name": "sessions_transcode_idx", + "columns": [ + { + "expression": "is_transcode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_platform_idx": { + "name": "sessions_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_movies_idx": { + "name": "sessions_top_movies_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_top_shows_idx": { + "name": "sessions_top_shows_idx", + "columns": [ + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grandparent_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_stale_detection_idx": { + "name": "sessions_stale_detection_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stopped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_server_user_id_server_users_id_fk": { + "name": "sessions_server_user_id_server_users_id_fk", + "tableFrom": "sessions", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "allow_guest_access": { + "name": "allow_guest_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unit_system": { + "name": "unit_system", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_webhook_url": { + "name": "custom_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_format": { + "name": "webhook_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poller_enabled": { + "name": "poller_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "poller_interval_ms": { + "name": "poller_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "tautulli_url": { + "name": "tautulli_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tautulli_api_key": { + "name": "tautulli_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_path": { + "name": "base_path", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "trust_proxy": { + "name": "trust_proxy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mobile_enabled": { + "name": "mobile_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "primary_auth_method": { + "name": "primary_auth_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termination_logs": { + "name": "termination_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "violation_id": { + "name": "violation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "termination_logs_session_idx": { + "name": "termination_logs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_server_user_idx": { + "name": "termination_logs_server_user_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_triggered_by_idx": { + "name": "termination_logs_triggered_by_idx", + "columns": [ + { + "expression": "triggered_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_rule_idx": { + "name": "termination_logs_rule_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "termination_logs_created_at_idx": { + "name": "termination_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "termination_logs_server_id_servers_id_fk": { + "name": "termination_logs_server_id_servers_id_fk", + "tableFrom": "termination_logs", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_server_user_id_server_users_id_fk": { + "name": "termination_logs_server_user_id_server_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termination_logs_triggered_by_user_id_users_id_fk": { + "name": "termination_logs_triggered_by_user_id_users_id_fk", + "tableFrom": "termination_logs", + "tableTo": "users", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_rule_id_rules_id_fk": { + "name": "termination_logs_rule_id_rules_id_fk", + "tableFrom": "termination_logs", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "termination_logs_violation_id_violations_id_fk": { + "name": "termination_logs_violation_id_violations_id_fk", + "tableFrom": "termination_logs", + "tableTo": "violations", + "columnsFrom": [ + "violation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plex_account_id": { + "name": "plex_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "aggregate_trust_score": { + "name": "aggregate_trust_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "total_violations": { + "name": "total_violations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_plex_account_id_idx": { + "name": "users_plex_account_id_idx", + "columns": [ + { + "expression": "plex_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.violations": { + "name": "violations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "server_user_id": { + "name": "server_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "violations_server_user_id_idx": { + "name": "violations_server_user_id_idx", + "columns": [ + { + "expression": "server_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_rule_id_idx": { + "name": "violations_rule_id_idx", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "violations_created_at_idx": { + "name": "violations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "violations_rule_id_rules_id_fk": { + "name": "violations_rule_id_rules_id_fk", + "tableFrom": "violations", + "tableTo": "rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_server_user_id_server_users_id_fk": { + "name": "violations_server_user_id_server_users_id_fk", + "tableFrom": "violations", + "tableTo": "server_users", + "columnsFrom": [ + "server_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "violations_session_id_sessions_id_fk": { + "name": "violations_session_id_sessions_id_fk", + "tableFrom": "violations", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..4ca658c --- /dev/null +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -0,0 +1,139 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1764697096357, + "tag": "0000_lying_dorian_gray", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764705329215, + "tag": "0001_graceful_starjammers", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1764788872702, + "tag": "0002_rainy_bishop", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1764799561925, + "tag": "0003_black_maginty", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1764800111127, + "tag": "0004_bent_unus", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1764801611462, + "tag": "0005_elite_wendell_vaughn", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1764806894704, + "tag": "0006_worthless_blue_shield", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1764865910195, + "tag": "0007_tense_pestilence", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1764871905960, + "tag": "0008_update_mobile_tokens_schema", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1764996689797, + "tag": "0009_quiet_vertigo", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1765214112454, + "tag": "0010_fair_zuras", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1765302271434, + "tag": "0011_breezy_ultron", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1765303740465, + "tag": "0012_strong_hannibal_king", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1765468947659, + "tag": "0013_same_rage", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1765479413354, + "tag": "0014_past_molly_hayes", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1765482999236, + "tag": "0015_gifted_the_liberteens", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1765571040426, + "tag": "0016_yummy_enchantress", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1765667812248, + "tag": "0017_broken_husk", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1765858132064, + "tag": "0018_robust_shotgun", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/src/db/prepared.ts b/apps/server/src/db/prepared.ts new file mode 100644 index 0000000..940fe98 --- /dev/null +++ b/apps/server/src/db/prepared.ts @@ -0,0 +1,325 @@ +/** + * Prepared statements for hot-path queries + * + * Prepared statements optimize performance by allowing PostgreSQL to reuse + * query plans across executions. These are particularly valuable for: + * - Queries called on every page load (dashboard) + * - Queries called frequently during polling + * - Queries with predictable parameter patterns + * + * @see https://orm.drizzle.team/docs/perf-queries + */ + +import { eq, gte, and, isNull, desc, sql } from 'drizzle-orm'; +import { db } from './client.js'; +import { sessions, violations, users, serverUsers, servers, rules } from './schema.js'; + +// ============================================================================ +// Dashboard Stats Queries +// ============================================================================ + +/** + * Count unique plays (grouped by reference_id) since a given date + * Used for: Dashboard "Today's Plays" metric + * Called: Every dashboard page load + */ +export const playsCountSince = db + .select({ + count: sql`count(DISTINCT COALESCE(reference_id, id))::int`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .prepare('plays_count_since'); + +/** + * Sum total watch time since a given date + * Used for: Dashboard "Watch Time" metric + * Called: Every dashboard page load + */ +export const watchTimeSince = db + .select({ + totalMs: sql`COALESCE(SUM(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .prepare('watch_time_since'); + +/** + * Count violations since a given date + * Used for: Dashboard "Alerts" metric + * Called: Every dashboard page load + */ +export const violationsCountSince = db + .select({ + count: sql`count(*)::int`, + }) + .from(violations) + .where(gte(violations.createdAt, sql.placeholder('since'))) + .prepare('violations_count_since'); + +/** + * Count unique active users since a given date + * Used for: Dashboard "Active Users Today" metric + * Called: Every dashboard page load + */ +export const uniqueUsersSince = db + .select({ + count: sql`count(DISTINCT server_user_id)::int`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .prepare('unique_users_since'); + +/** + * Count unacknowledged violations + * Used for: Alert badge in navigation + * Called: On app load and after acknowledgment + */ +export const unacknowledgedViolationsCount = db + .select({ + count: sql`count(*)::int`, + }) + .from(violations) + .where(isNull(violations.acknowledgedAt)) + .prepare('unacknowledged_violations_count'); + +// ============================================================================ +// Polling Queries +// ============================================================================ + +/** + * Find server user by server ID and external ID + * Used for: Server user lookup during session polling + * Called: Every poll cycle for each active session (potentially 10+ times per 15 seconds) + */ +export const serverUserByExternalId = db + .select() + .from(serverUsers) + .where( + and( + eq(serverUsers.serverId, sql.placeholder('serverId')), + eq(serverUsers.externalId, sql.placeholder('externalId')) + ) + ) + .limit(1) + .prepare('server_user_by_external_id'); + +/** + * Find session by server ID and session key + * Used for: Session lookup during polling to check for existing sessions + * Called: Every poll cycle for each active session + */ +export const sessionByServerAndKey = db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, sql.placeholder('serverId')), + eq(sessions.sessionKey, sql.placeholder('sessionKey')) + ) + ) + .limit(1) + .prepare('session_by_server_and_key'); + +// ============================================================================ +// User Queries +// ============================================================================ + +/** + * Get server user by ID with basic info + * Used for: Server user details in violations, sessions + * Called: Frequently for UI enrichment + */ +export const serverUserById = db + .select({ + id: serverUsers.id, + userId: serverUsers.userId, + username: serverUsers.username, + thumbUrl: serverUsers.thumbUrl, + trustScore: serverUsers.trustScore, + }) + .from(serverUsers) + .where(eq(serverUsers.id, sql.placeholder('id'))) + .limit(1) + .prepare('server_user_by_id'); + +/** + * Get user identity by ID + * Used for: User identity info (the real person) + * Called: When viewing user profile + */ +export const userById = db + .select({ + id: users.id, + name: users.name, + thumbnail: users.thumbnail, + email: users.email, + role: users.role, + aggregateTrustScore: users.aggregateTrustScore, + }) + .from(users) + .where(eq(users.id, sql.placeholder('id'))) + .limit(1) + .prepare('user_by_id'); + +// ============================================================================ +// Session Queries +// ============================================================================ + +/** + * Get session by ID + * Used for: Session detail page, violation context + * Called: When viewing session details + */ +export const sessionById = db + .select() + .from(sessions) + .where(eq(sessions.id, sql.placeholder('id'))) + .limit(1) + .prepare('session_by_id'); + +// ============================================================================ +// Stats Queries (hot-path for dashboard and analytics pages) +// ============================================================================ + +/** + * Plays by platform since a given date + * Used for: Stats platform breakdown chart + * Called: Every stats page load + */ +export const playsByPlatformSince = db + .select({ + platform: sessions.platform, + count: sql`count(DISTINCT COALESCE(reference_id, id))::int`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .groupBy(sessions.platform) + .orderBy(sql`count(DISTINCT COALESCE(reference_id, id)) DESC`) + .prepare('plays_by_platform_since'); + +/** + * Quality breakdown (direct vs transcode) since a given date + * Used for: Stats quality chart + * Called: Every stats page load + */ +export const qualityStatsSince = db + .select({ + isTranscode: sessions.isTranscode, + count: sql`count(DISTINCT COALESCE(reference_id, id))::int`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .groupBy(sessions.isTranscode) + .prepare('quality_stats_since'); + +/** + * Watch time by media type since a given date + * Used for: Watch time breakdown by content type + * Called: Stats page load + */ +export const watchTimeByTypeSince = db + .select({ + mediaType: sessions.mediaType, + totalMs: sql`COALESCE(SUM(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(gte(sessions.startedAt, sql.placeholder('since'))) + .groupBy(sessions.mediaType) + .prepare('watch_time_by_type_since'); + +// ============================================================================ +// Rule Queries (hot-path for poller) +// ============================================================================ + +/** + * Get all active rules + * Used for: Rule evaluation during session polling + * Called: Every poll cycle (~15 seconds per server) + */ +export const getActiveRules = db + .select() + .from(rules) + .where(eq(rules.isActive, true)) + .prepare('get_active_rules'); + +/** + * Get recent sessions for a user (for rule evaluation) + * Used for: Evaluating device velocity, concurrent streams rules + * Called: During rule evaluation for active sessions + */ +export const getUserRecentSessions = db + .select({ + id: sessions.id, + startedAt: sessions.startedAt, + stoppedAt: sessions.stoppedAt, + ipAddress: sessions.ipAddress, + deviceId: sessions.deviceId, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + geoCity: sessions.geoCity, + geoCountry: sessions.geoCountry, + state: sessions.state, + }) + .from(sessions) + .where( + and( + eq(sessions.serverUserId, sql.placeholder('serverUserId')), + gte(sessions.startedAt, sql.placeholder('since')) + ) + ) + .orderBy(desc(sessions.startedAt)) + .limit(100) + .prepare('get_user_recent_sessions'); + +// ============================================================================ +// Violation Queries +// ============================================================================ + +/** + * Get unacknowledged violations with pagination + * Used for: Violation list in dashboard + * Called: Frequently for alert displays + */ +export const getUnackedViolations = db + .select() + .from(violations) + .where(isNull(violations.acknowledgedAt)) + .orderBy(desc(violations.createdAt)) + .limit(sql.placeholder('limit')) + .prepare('get_unacked_violations'); + +// ============================================================================ +// Server Queries +// ============================================================================ + +/** + * Get server by ID + * Used for: Server details, validation + * Called: Frequently during API requests + */ +export const serverById = db + .select() + .from(servers) + .where(eq(servers.id, sql.placeholder('id'))) + .limit(1) + .prepare('server_by_id'); + +// ============================================================================ +// Type exports for execute results +// ============================================================================ + +export type PlaysCountResult = Awaited>; +export type WatchTimeResult = Awaited>; +export type ViolationsCountResult = Awaited>; +export type ServerUserByExternalIdResult = Awaited>; +export type ServerUserByIdResult = Awaited>; +export type UserByIdResult = Awaited>; +export type SessionByIdResult = Awaited>; +export type PlaysByPlatformResult = Awaited>; +export type QualityStatsResult = Awaited>; +export type WatchTimeByTypeResult = Awaited>; +export type ActiveRulesResult = Awaited>; +export type UserRecentSessionsResult = Awaited>; +export type UnackedViolationsResult = Awaited>; +export type ServerByIdResult = Awaited>; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..fd22a4e --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,624 @@ +/** + * Drizzle ORM schema definitions for Tracearr + * + * Multi-Server User Architecture: + * - `users` = Identity (the real human) + * - `server_users` = Account on a specific server (Plex/Jellyfin/Emby) + * - One user can have multiple server_users (accounts across servers) + * - Sessions and violations link to server_users (server-specific) + */ + +import { + pgTable, + uuid, + varchar, + text, + timestamp, + boolean, + integer, + real, + jsonb, + index, + uniqueIndex, + check, +} from 'drizzle-orm/pg-core'; +import { relations, sql } from 'drizzle-orm'; + +// Server types enum +export const serverTypeEnum = ['plex', 'jellyfin', 'emby'] as const; + +// Session state enum +export const sessionStateEnum = ['playing', 'paused', 'stopped'] as const; + +// Media type enum +export const mediaTypeEnum = ['movie', 'episode', 'track'] as const; + +// Rule type enum +export const ruleTypeEnum = [ + 'impossible_travel', + 'simultaneous_locations', + 'device_velocity', + 'concurrent_streams', + 'geo_restriction', +] as const; + +// Violation severity enum +export const violationSeverityEnum = ['low', 'warning', 'high'] as const; + +// Media servers (Plex/Jellyfin/Emby instances) +export const servers = pgTable('servers', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 100 }).notNull(), + type: varchar('type', { length: 20 }).notNull().$type<(typeof serverTypeEnum)[number]>(), + url: text('url').notNull(), + token: text('token').notNull(), // Encrypted + machineIdentifier: varchar('machine_identifier', { length: 100 }), // Plex clientIdentifier for dedup + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * Users - Identity table representing real humans + * + * This is the "anchor" identity that can own multiple server accounts. + * Stores authentication credentials and aggregated metrics. + */ +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Identity + username: varchar('username', { length: 100 }).notNull(), // Login identifier (unique) + name: varchar('name', { length: 255 }), // Display name (optional, defaults to null) + thumbnail: text('thumbnail'), // Custom avatar (nullable) + email: varchar('email', { length: 255 }), // For identity matching (nullable) + + // Authentication (nullable - not all users authenticate directly) + passwordHash: text('password_hash'), // bcrypt hash for local login + plexAccountId: varchar('plex_account_id', { length: 255 }), // Plex.tv global account ID for OAuth + + // Access control - combined permission level and account status + // Can log in: 'owner', 'admin', 'viewer' + // Cannot log in: 'member' (default), 'disabled', 'pending' + role: varchar('role', { length: 20 }) + .notNull() + .$type<'owner' | 'admin' | 'viewer' | 'member' | 'disabled' | 'pending'>() + .default('member'), + + // Aggregated metrics (cached, updated by triggers) + aggregateTrustScore: integer('aggregate_trust_score').notNull().default(100), + totalViolations: integer('total_violations').notNull().default(0), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + // Username is display name from media server (not unique across servers) + index('users_username_idx').on(table.username), + uniqueIndex('users_email_unique').on(table.email), + index('users_plex_account_id_idx').on(table.plexAccountId), + index('users_role_idx').on(table.role), + ] +); + +/** + * Server Users - Account on a specific media server + * + * Represents a user's account on a Plex/Jellyfin/Emby server. + * One user (identity) can have multiple server_users (accounts across servers). + * Sessions and violations link here for per-server tracking. + */ +export const serverUsers = pgTable( + 'server_users', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relationships - always linked to both user and server + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + serverId: uuid('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + + // Server-specific identity + externalId: varchar('external_id', { length: 255 }).notNull(), // Plex/Jellyfin user ID + username: varchar('username', { length: 255 }).notNull(), // Username on this server + email: varchar('email', { length: 255 }), // Email from server sync (may differ from users.email) + thumbUrl: text('thumb_url'), // Avatar from server + + // Server-specific permissions + isServerAdmin: boolean('is_server_admin').notNull().default(false), + + // Per-server trust + trustScore: integer('trust_score').notNull().default(100), + sessionCount: integer('session_count').notNull().default(0), // For aggregate weighting + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + // One account per user per server + uniqueIndex('server_users_user_server_unique').on(table.userId, table.serverId), + // Atomic upsert during sync + uniqueIndex('server_users_server_external_unique').on(table.serverId, table.externalId), + // Query optimization + index('server_users_user_idx').on(table.userId), + index('server_users_server_idx').on(table.serverId), + index('server_users_username_idx').on(table.username), + ] +); + +// Session history (will be converted to hypertable) +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + serverId: uuid('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + // Links to server_users for per-server tracking + serverUserId: uuid('server_user_id') + .notNull() + .references(() => serverUsers.id, { onDelete: 'cascade' }), + sessionKey: varchar('session_key', { length: 255 }).notNull(), + // Plex Session.id - required for termination API (different from sessionKey) + // For Jellyfin/Emby, sessionKey is used directly for termination + plexSessionId: varchar('plex_session_id', { length: 255 }), + state: varchar('state', { length: 20 }).notNull().$type<(typeof sessionStateEnum)[number]>(), + mediaType: varchar('media_type', { length: 20 }) + .notNull() + .$type<(typeof mediaTypeEnum)[number]>(), + mediaTitle: text('media_title').notNull(), + // Enhanced media metadata for episodes + grandparentTitle: varchar('grandparent_title', { length: 500 }), // Show name (for episodes) + seasonNumber: integer('season_number'), // Season number (for episodes) + episodeNumber: integer('episode_number'), // Episode number (for episodes) + year: integer('year'), // Release year + thumbPath: varchar('thumb_path', { length: 500 }), // Poster path (e.g., /library/metadata/123/thumb) + ratingKey: varchar('rating_key', { length: 255 }), // Plex/Jellyfin media identifier + externalSessionId: varchar('external_session_id', { length: 255 }), // External reference for deduplication + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + stoppedAt: timestamp('stopped_at', { withTimezone: true }), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(), // Last time session was seen in poll (for stale detection) - no default, app always provides + durationMs: integer('duration_ms'), // Actual watch duration (excludes paused time) + totalDurationMs: integer('total_duration_ms'), // Total media length + progressMs: integer('progress_ms'), // Current playback position + // Pause tracking - accumulates total paused time across pause/resume cycles + lastPausedAt: timestamp('last_paused_at', { withTimezone: true }), // When current pause started + pausedDurationMs: integer('paused_duration_ms').notNull().default(0), // Accumulated pause time + // Session grouping for "resume where left off" tracking + referenceId: uuid('reference_id'), // Links to first session in resume chain + watched: boolean('watched').notNull().default(false), // True if user watched 85%+ + forceStopped: boolean('force_stopped').notNull().default(false), // True if session was force-stopped due to inactivity + shortSession: boolean('short_session').notNull().default(false), // True if session duration < MIN_PLAY_TIME_MS (120s) + ipAddress: varchar('ip_address', { length: 45 }).notNull(), + geoCity: varchar('geo_city', { length: 255 }), + geoRegion: varchar('geo_region', { length: 255 }), // State/province/subdivision + geoCountry: varchar('geo_country', { length: 100 }), + geoLat: real('geo_lat'), + geoLon: real('geo_lon'), + playerName: varchar('player_name', { length: 255 }), // Player title/friendly name + deviceId: varchar('device_id', { length: 255 }), // Machine identifier (unique device UUID) + product: varchar('product', { length: 255 }), // Product name (e.g., "Plex for iOS") + device: varchar('device', { length: 255 }), // Device type (e.g., "iPhone", "Android TV") + platform: varchar('platform', { length: 100 }), + quality: varchar('quality', { length: 100 }), + isTranscode: boolean('is_transcode').notNull().default(false), + bitrate: integer('bitrate'), + }, + (table) => [ + index('sessions_server_user_time_idx').on(table.serverUserId, table.startedAt), + index('sessions_server_time_idx').on(table.serverId, table.startedAt), + index('sessions_state_idx').on(table.state), + index('sessions_external_session_idx').on(table.serverId, table.externalSessionId), + index('sessions_device_idx').on(table.serverUserId, table.deviceId), + index('sessions_reference_idx').on(table.referenceId), // For session grouping queries + index('sessions_server_user_rating_idx').on(table.serverUserId, table.ratingKey), // For resume detection + // Index for Tautulli import deduplication fallback (when externalSessionId not found) + index('sessions_dedup_fallback_idx').on( + table.serverId, + table.serverUserId, + table.ratingKey, + table.startedAt + ), + // Indexes for stats queries + index('sessions_geo_idx').on(table.geoLat, table.geoLon), // For /stats/locations basic geo lookup + index('sessions_geo_time_idx').on(table.startedAt, table.geoLat, table.geoLon), // For time-filtered map queries + index('sessions_media_type_idx').on(table.mediaType), // For media type aggregations + index('sessions_transcode_idx').on(table.isTranscode), // For quality stats + index('sessions_platform_idx').on(table.platform), // For platform stats + // Indexes for top-content queries (movies and shows aggregation) + index('sessions_top_movies_idx').on(table.mediaType, table.mediaTitle, table.year), // For top movies GROUP BY + index('sessions_top_shows_idx').on(table.mediaType, table.grandparentTitle), // For top shows GROUP BY series + // Index for stale session detection (active sessions that haven't been seen recently) + index('sessions_stale_detection_idx').on(table.lastSeenAt, table.stoppedAt), + ] +); + +// Sharing detection rules +export const rules = pgTable( + 'rules', + { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 100 }).notNull(), + type: varchar('type', { length: 50 }).notNull().$type<(typeof ruleTypeEnum)[number]>(), + params: jsonb('params').notNull().$type>(), + // Nullable: null = global rule, set = specific server user + serverUserId: uuid('server_user_id').references(() => serverUsers.id, { onDelete: 'cascade' }), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('rules_active_idx').on(table.isActive), + index('rules_server_user_id_idx').on(table.serverUserId), + ] +); + +// Rule violations +export const violations = pgTable( + 'violations', + { + id: uuid('id').primaryKey().defaultRandom(), + ruleId: uuid('rule_id') + .notNull() + .references(() => rules.id, { onDelete: 'cascade' }), + // Links to server_users for per-server tracking + serverUserId: uuid('server_user_id') + .notNull() + .references(() => serverUsers.id, { onDelete: 'cascade' }), + sessionId: uuid('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + severity: varchar('severity', { length: 20 }) + .notNull() + .$type<(typeof violationSeverityEnum)[number]>(), + data: jsonb('data').notNull().$type>(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }), + }, + (table) => [ + index('violations_server_user_id_idx').on(table.serverUserId), + index('violations_rule_id_idx').on(table.ruleId), + index('violations_created_at_idx').on(table.createdAt), + ] +); + +// Mobile pairing tokens (one-time use, expire after 15 minutes) +export const mobileTokens = pgTable('mobile_tokens', { + id: uuid('id').primaryKey().defaultRandom(), + tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(), // SHA-256 of trr_mob_xxx token + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'cascade' }), + usedAt: timestamp('used_at', { withTimezone: true }), // Set when token is used, null = unused +}); + +// Mobile sessions (paired devices) +export const mobileSessions = pgTable( + 'mobile_sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + // Link to user identity for multi-user support + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + refreshTokenHash: varchar('refresh_token_hash', { length: 64 }).notNull().unique(), // SHA-256 + deviceName: varchar('device_name', { length: 100 }).notNull(), + deviceId: varchar('device_id', { length: 100 }).notNull(), + platform: varchar('platform', { length: 20 }).notNull().$type<'ios' | 'android'>(), + expoPushToken: varchar('expo_push_token', { length: 255 }), // For push notifications + deviceSecret: varchar('device_secret', { length: 64 }), // For push payload encryption (base64) + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('mobile_sessions_user_idx').on(table.userId), + index('mobile_sessions_device_id_idx').on(table.deviceId), + index('mobile_sessions_refresh_token_idx').on(table.refreshTokenHash), + index('mobile_sessions_expo_push_token_idx').on(table.expoPushToken), + ] +); + +// Notification preferences per mobile device +export const notificationPreferences = pgTable( + 'notification_preferences', + { + id: uuid('id').primaryKey().defaultRandom(), + mobileSessionId: uuid('mobile_session_id') + .notNull() + .unique() + .references(() => mobileSessions.id, { onDelete: 'cascade' }), + + // Global toggles + pushEnabled: boolean('push_enabled').notNull().default(true), + + // Event type toggles + onViolationDetected: boolean('on_violation_detected').notNull().default(true), + onStreamStarted: boolean('on_stream_started').notNull().default(false), + onStreamStopped: boolean('on_stream_stopped').notNull().default(false), + onConcurrentStreams: boolean('on_concurrent_streams').notNull().default(true), + onNewDevice: boolean('on_new_device').notNull().default(true), + onTrustScoreChanged: boolean('on_trust_score_changed').notNull().default(false), + onServerDown: boolean('on_server_down').notNull().default(true), + onServerUp: boolean('on_server_up').notNull().default(true), + + // Severity filtering (violations only) + violationMinSeverity: integer('violation_min_severity').notNull().default(1), // 1=low, 2=warning, 3=high + violationRuleTypes: text('violation_rule_types').array().default([]), // Empty = all types + + // Rate limiting + maxPerMinute: integer('max_per_minute').notNull().default(10), + maxPerHour: integer('max_per_hour').notNull().default(60), + + // Quiet hours + quietHoursEnabled: boolean('quiet_hours_enabled').notNull().default(false), + quietHoursStart: varchar('quiet_hours_start', { length: 5 }), // HH:MM format + quietHoursEnd: varchar('quiet_hours_end', { length: 5 }), // HH:MM format + quietHoursTimezone: varchar('quiet_hours_timezone', { length: 50 }).default('UTC'), + quietHoursOverrideCritical: boolean('quiet_hours_override_critical').notNull().default(true), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('notification_prefs_mobile_session_idx').on(table.mobileSessionId), + // Validate quiet hours format: HH:MM where HH is 00-23 and MM is 00-59 + check( + 'quiet_hours_start_format', + sql`${table.quietHoursStart} IS NULL OR ${table.quietHoursStart} ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'` + ), + check( + 'quiet_hours_end_format', + sql`${table.quietHoursEnd} IS NULL OR ${table.quietHoursEnd} ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'` + ), + ] +); + +// Notification event type enum +export const notificationEventTypeEnum = [ + 'violation_detected', + 'stream_started', + 'stream_stopped', + 'concurrent_streams', + 'new_device', + 'trust_score_changed', + 'server_down', + 'server_up', +] as const; + +// Notification channel routing configuration +// Controls which channels receive which event types (web admin configurable) +export const notificationChannelRouting = pgTable( + 'notification_channel_routing', + { + id: uuid('id').primaryKey().defaultRandom(), + eventType: varchar('event_type', { length: 50 }) + .notNull() + .unique() + .$type<(typeof notificationEventTypeEnum)[number]>(), + + // Channel toggles + discordEnabled: boolean('discord_enabled').notNull().default(true), + webhookEnabled: boolean('webhook_enabled').notNull().default(true), + pushEnabled: boolean('push_enabled').notNull().default(true), + webToastEnabled: boolean('web_toast_enabled').notNull().default(true), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('notification_channel_routing_event_type_idx').on(table.eventType)] +); + +// Termination trigger type enum +export const terminationTriggerEnum = ['manual', 'rule'] as const; + +// Stream termination audit log +export const terminationLogs = pgTable( + 'termination_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + + // What was terminated + // Note: No FK constraint because sessions is a TimescaleDB hypertable + // (hypertables don't support foreign key references to their primary key) + // The relationship is maintained via Drizzle ORM relations + sessionId: uuid('session_id').notNull(), + serverId: uuid('server_id') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + // The user whose stream was terminated + serverUserId: uuid('server_user_id') + .notNull() + .references(() => serverUsers.id, { onDelete: 'cascade' }), + + // How it was triggered + trigger: varchar('trigger', { length: 20 }) + .notNull() + .$type<(typeof terminationTriggerEnum)[number]>(), + + // Who triggered it (for manual) - nullable for rule-triggered + triggeredByUserId: uuid('triggered_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + + // What rule triggered it (for rule-triggered) - nullable for manual + ruleId: uuid('rule_id').references(() => rules.id, { onDelete: 'set null' }), + violationId: uuid('violation_id').references(() => violations.id, { onDelete: 'set null' }), + + // Message shown to user (Plex only) + reason: text('reason'), + + // Result + success: boolean('success').notNull(), + errorMessage: text('error_message'), // If success=false + + // Timestamp + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('termination_logs_session_idx').on(table.sessionId), + index('termination_logs_server_user_idx').on(table.serverUserId), + index('termination_logs_triggered_by_idx').on(table.triggeredByUserId), + index('termination_logs_rule_idx').on(table.ruleId), + index('termination_logs_created_at_idx').on(table.createdAt), + ] +); + +// Unit system enum for display preferences +export const unitSystemEnum = ['metric', 'imperial'] as const; + +// Application settings (single row) +export const settings = pgTable('settings', { + id: integer('id').primaryKey().default(1), + allowGuestAccess: boolean('allow_guest_access').notNull().default(false), + // Display preferences + unitSystem: varchar('unit_system', { length: 20 }) + .notNull() + .$type<(typeof unitSystemEnum)[number]>() + .default('metric'), + discordWebhookUrl: text('discord_webhook_url'), + customWebhookUrl: text('custom_webhook_url'), + webhookFormat: text('webhook_format').$type<'json' | 'ntfy' | 'apprise'>(), // Format for custom webhook payloads + ntfyTopic: text('ntfy_topic'), // Topic for ntfy notifications (required when webhookFormat is 'ntfy') + // Poller settings + pollerEnabled: boolean('poller_enabled').notNull().default(true), + pollerIntervalMs: integer('poller_interval_ms').notNull().default(15000), + // Tautulli integration + tautulliUrl: text('tautulli_url'), + tautulliApiKey: text('tautulli_api_key'), // Encrypted + // Network/access settings for self-hosted deployments + externalUrl: text('external_url'), // Public URL for mobile/external access (e.g., https://tracearr.example.com) + basePath: varchar('base_path', { length: 100 }).notNull().default(''), // For subfolder proxies (e.g., /tracearr) + trustProxy: boolean('trust_proxy').notNull().default(false), // Trust X-Forwarded-* headers from reverse proxy + // Mobile access + mobileEnabled: boolean('mobile_enabled').notNull().default(false), + // Authentication settings + primaryAuthMethod: varchar('primary_auth_method', { length: 20 }) + .$type<'jellyfin' | 'local'>() + .notNull() + .default('local'), // Default to local auth + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +// ============================================================================ +// Relations +// ============================================================================ + +export const serversRelations = relations(servers, ({ many }) => ({ + serverUsers: many(serverUsers), + sessions: many(sessions), +})); + +export const usersRelations = relations(users, ({ many }) => ({ + serverUsers: many(serverUsers), + mobileSessions: many(mobileSessions), + mobileTokens: many(mobileTokens), +})); + +export const serverUsersRelations = relations(serverUsers, ({ one, many }) => ({ + user: one(users, { + fields: [serverUsers.userId], + references: [users.id], + }), + server: one(servers, { + fields: [serverUsers.serverId], + references: [servers.id], + }), + sessions: many(sessions), + rules: many(rules), + violations: many(violations), +})); + +export const sessionsRelations = relations(sessions, ({ one, many }) => ({ + server: one(servers, { + fields: [sessions.serverId], + references: [servers.id], + }), + serverUser: one(serverUsers, { + fields: [sessions.serverUserId], + references: [serverUsers.id], + }), + violations: many(violations), +})); + +export const rulesRelations = relations(rules, ({ one, many }) => ({ + serverUser: one(serverUsers, { + fields: [rules.serverUserId], + references: [serverUsers.id], + }), + violations: many(violations), +})); + +export const violationsRelations = relations(violations, ({ one }) => ({ + rule: one(rules, { + fields: [violations.ruleId], + references: [rules.id], + }), + serverUser: one(serverUsers, { + fields: [violations.serverUserId], + references: [serverUsers.id], + }), + session: one(sessions, { + fields: [violations.sessionId], + references: [sessions.id], + }), +})); + +export const mobileSessionsRelations = relations(mobileSessions, ({ one }) => ({ + user: one(users, { + fields: [mobileSessions.userId], + references: [users.id], + }), + notificationPreferences: one(notificationPreferences, { + fields: [mobileSessions.id], + references: [notificationPreferences.mobileSessionId], + }), +})); + +export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({ + mobileSession: one(mobileSessions, { + fields: [notificationPreferences.mobileSessionId], + references: [mobileSessions.id], + }), +})); + +export const mobileTokensRelations = relations(mobileTokens, ({ one }) => ({ + createdByUser: one(users, { + fields: [mobileTokens.createdBy], + references: [users.id], + }), +})); + +export const terminationLogsRelations = relations(terminationLogs, ({ one }) => ({ + session: one(sessions, { + fields: [terminationLogs.sessionId], + references: [sessions.id], + }), + server: one(servers, { + fields: [terminationLogs.serverId], + references: [servers.id], + }), + serverUser: one(serverUsers, { + fields: [terminationLogs.serverUserId], + references: [serverUsers.id], + }), + triggeredByUser: one(users, { + fields: [terminationLogs.triggeredByUserId], + references: [users.id], + }), + rule: one(rules, { + fields: [terminationLogs.ruleId], + references: [rules.id], + }), + violation: one(violations, { + fields: [terminationLogs.violationId], + references: [violations.id], + }), +})); diff --git a/apps/server/src/db/timescale.ts b/apps/server/src/db/timescale.ts new file mode 100644 index 0000000..ce7216a --- /dev/null +++ b/apps/server/src/db/timescale.ts @@ -0,0 +1,649 @@ +/** + * TimescaleDB initialization and setup + * + * This module ensures TimescaleDB features are properly configured for the sessions table. + * It runs on every server startup and is idempotent - safe to run multiple times. + */ + +import { db } from './client.js'; +import { sql } from 'drizzle-orm'; + +export interface TimescaleStatus { + extensionInstalled: boolean; + sessionsIsHypertable: boolean; + compressionEnabled: boolean; + continuousAggregates: string[]; + chunkCount: number; +} + +/** + * Check if TimescaleDB extension is available + */ +async function isTimescaleInstalled(): Promise { + try { + const result = await db.execute(sql` + SELECT EXISTS( + SELECT 1 FROM pg_extension WHERE extname = 'timescaledb' + ) as installed + `); + return (result.rows[0] as { installed: boolean })?.installed ?? false; + } catch { + return false; + } +} + +/** + * Check if sessions table is already a hypertable + */ +async function isSessionsHypertable(): Promise { + try { + const result = await db.execute(sql` + SELECT EXISTS( + SELECT 1 FROM timescaledb_information.hypertables + WHERE hypertable_name = 'sessions' + ) as is_hypertable + `); + return (result.rows[0] as { is_hypertable: boolean })?.is_hypertable ?? false; + } catch { + // If timescaledb_information doesn't exist, extension isn't installed + return false; + } +} + +/** + * Get list of existing continuous aggregates + */ +async function getContinuousAggregates(): Promise { + try { + const result = await db.execute(sql` + SELECT view_name + FROM timescaledb_information.continuous_aggregates + WHERE hypertable_name = 'sessions' + `); + return (result.rows as { view_name: string }[]).map((r) => r.view_name); + } catch { + return []; + } +} + +/** + * Check if compression is enabled on sessions + */ +async function isCompressionEnabled(): Promise { + try { + const result = await db.execute(sql` + SELECT compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_name = 'sessions' + `); + return (result.rows[0] as { compression_enabled: boolean })?.compression_enabled ?? false; + } catch { + return false; + } +} + +/** + * Get chunk count for sessions hypertable + */ +async function getChunkCount(): Promise { + try { + const result = await db.execute(sql` + SELECT count(*)::int as count + FROM timescaledb_information.chunks + WHERE hypertable_name = 'sessions' + `); + return (result.rows[0] as { count: number })?.count ?? 0; + } catch { + return 0; + } +} + +/** + * Convert sessions table to hypertable + * This is idempotent - if_not_exists ensures it won't fail if already a hypertable + */ +async function convertToHypertable(): Promise { + // First, we need to handle the primary key change + // TimescaleDB requires the partition column (started_at) in the primary key + + // Check if we need to modify the primary key + const pkResult = await db.execute(sql` + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_name = 'sessions' + AND constraint_type = 'PRIMARY KEY' + `); + + const pkName = (pkResult.rows[0] as { constraint_name: string })?.constraint_name; + + // Check if started_at is already in the primary key + const pkColsResult = await db.execute(sql` + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_name = 'sessions' + AND constraint_name = ${pkName} + `); + + const pkColumns = (pkColsResult.rows as { column_name: string }[]).map((r) => r.column_name); + + if (!pkColumns.includes('started_at')) { + // Need to modify primary key for hypertable conversion + + // Drop FK constraint from violations if it exists + await db.execute(sql` + ALTER TABLE "violations" DROP CONSTRAINT IF EXISTS "violations_session_id_sessions_id_fk" + `); + + // Drop existing primary key + if (pkName) { + await db.execute(sql.raw(`ALTER TABLE "sessions" DROP CONSTRAINT IF EXISTS "${pkName}"`)); + } + + // Add composite primary key + await db.execute(sql` + ALTER TABLE "sessions" ADD PRIMARY KEY ("id", "started_at") + `); + + // Add index for violations session lookup (since we can't have FK to hypertable) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "violations_session_lookup_idx" ON "violations" ("session_id") + `); + } + + // Convert to hypertable + await db.execute(sql` + SELECT create_hypertable('sessions', 'started_at', + chunk_time_interval => INTERVAL '7 days', + migrate_data => true, + if_not_exists => true + ) + `); + + // Create expression indexes for COALESCE(reference_id, id) pattern + // This pattern is used throughout the codebase for play grouping + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_play_id + ON sessions ((COALESCE(reference_id, id))) + `); + + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_time_play_id + ON sessions (started_at DESC, (COALESCE(reference_id, id))) + `); + + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_user_play_id + ON sessions (server_user_id, (COALESCE(reference_id, id))) + `); +} + +/** + * Create partial indexes for common filtered queries + * These reduce scan size by excluding irrelevant rows + */ +async function createPartialIndexes(): Promise { + // Partial index for geo queries (excludes NULL rows - ~20% savings) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_geo_partial + ON sessions (geo_lat, geo_lon, started_at DESC) + WHERE geo_lat IS NOT NULL AND geo_lon IS NOT NULL + `); + + // Partial index for unacknowledged violations by user (hot path for user-specific alerts) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_violations_unacked_partial + ON violations (server_user_id, created_at DESC) + WHERE acknowledged_at IS NULL + `); + + // Partial index for unacknowledged violations list (hot path for main violations list) + // This index is optimized for the common query: ORDER BY created_at DESC WHERE acknowledged_at IS NULL + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_violations_unacked_list + ON violations (created_at DESC) + WHERE acknowledged_at IS NULL + `); + + // Partial index for active/playing sessions + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_active_partial + ON sessions (server_id, server_user_id, started_at DESC) + WHERE state = 'playing' + `); + + // Partial index for transcoded sessions (quality analysis) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_transcode_partial + ON sessions (started_at DESC, quality, bitrate) + WHERE is_transcode = true + `); +} + +/** + * Create optimized indexes for top content queries + * Time-prefixed indexes enable efficient time-filtered aggregations + */ +async function createContentIndexes(): Promise { + // Time-prefixed index for media title queries + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_media_time + ON sessions (started_at DESC, media_type, media_title) + `); + + // Time-prefixed index for show/episode queries (excludes NULLs) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_show_time + ON sessions (started_at DESC, grandparent_title, season_number, episode_number) + WHERE grandparent_title IS NOT NULL + `); + + // Covering index for top content query (includes frequently accessed columns) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_top_content_covering + ON sessions (started_at DESC, media_title, media_type) + INCLUDE (duration_ms, server_user_id) + `); + + // Device tracking index for device velocity rule + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_sessions_device_tracking + ON sessions (server_user_id, started_at DESC, device_id, ip_address) + `); +} + +/** + * Check if TimescaleDB Toolkit is installed + */ +async function isToolkitInstalled(): Promise { + try { + const result = await db.execute(sql` + SELECT EXISTS( + SELECT 1 FROM pg_extension WHERE extname = 'timescaledb_toolkit' + ) as installed + `); + return (result.rows[0] as { installed: boolean })?.installed ?? false; + } catch { + return false; + } +} + +/** + * Check if TimescaleDB Toolkit is available to be installed on the system + */ +async function isToolkitAvailableOnSystem(): Promise { + try { + const result = await db.execute(sql` + SELECT EXISTS( + SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb_toolkit' + ) as available + `); + return (result.rows[0] as { available: boolean })?.available ?? false; + } catch { + return false; + } +} + +/** + * Create continuous aggregates for dashboard performance + * + * Uses HyperLogLog from TimescaleDB Toolkit for approximate distinct counts + * (99.5% accuracy) since TimescaleDB doesn't support COUNT(DISTINCT) in + * continuous aggregates. Falls back to COUNT(*) if Toolkit unavailable. + */ +async function createContinuousAggregates(): Promise { + const hasToolkit = await isToolkitInstalled(); + + // Drop old unused aggregates + // daily_plays_by_platform: platform stats use prepared statement instead + // daily_play_patterns/hourly_play_patterns: never wired up, missing server_id for multi-server filtering + await db.execute(sql`DROP MATERIALIZED VIEW IF EXISTS daily_plays_by_platform CASCADE`); + await db.execute(sql`DROP MATERIALIZED VIEW IF EXISTS daily_play_patterns CASCADE`); + await db.execute(sql`DROP MATERIALIZED VIEW IF EXISTS hourly_play_patterns CASCADE`); + + if (hasToolkit) { + // Use HyperLogLog for accurate distinct play counting + // hyperloglog(32768, ...) gives ~0.4% error rate + + // Daily plays by user with HyperLogLog + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_plays_by_user + WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS + SELECT + time_bucket('1 day', started_at) AS day, + server_user_id, + hyperloglog(32768, COALESCE(reference_id, id)) AS plays_hll, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms + FROM sessions + GROUP BY day, server_user_id + WITH NO DATA + `); + + // Daily plays by server with HyperLogLog + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_plays_by_server + WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS + SELECT + time_bucket('1 day', started_at) AS day, + server_id, + hyperloglog(32768, COALESCE(reference_id, id)) AS plays_hll, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms + FROM sessions + GROUP BY day, server_id + WITH NO DATA + `); + + // Daily stats summary (main dashboard aggregate) with HyperLogLog + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats_summary + WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS + SELECT + time_bucket('1 day', started_at) AS day, + hyperloglog(32768, COALESCE(reference_id, id)) AS plays_hll, + hyperloglog(32768, server_user_id) AS users_hll, + hyperloglog(32768, server_id) AS servers_hll, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms, + AVG(COALESCE(duration_ms, 0))::bigint AS avg_duration_ms + FROM sessions + GROUP BY day + WITH NO DATA + `); + + // Hourly concurrent streams (used by /concurrent endpoint) + // Note: This uses COUNT(*) since concurrent streams isn't about unique plays + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_concurrent_streams + WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS + SELECT + time_bucket('1 hour', started_at) AS hour, + server_id, + COUNT(*) AS stream_count + FROM sessions + WHERE state IN ('playing', 'paused') + GROUP BY hour, server_id + WITH NO DATA + `); + } else { + // Fallback: Standard aggregates without HyperLogLog + // Note: These use COUNT(*) which overcounts resumed sessions + console.warn('TimescaleDB Toolkit not available - using COUNT(*) aggregates'); + + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_plays_by_user + WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 day', started_at) AS day, + server_user_id, + COUNT(*) AS play_count, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms + FROM sessions + GROUP BY day, server_user_id + WITH NO DATA + `); + + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_plays_by_server + WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 day', started_at) AS day, + server_id, + COUNT(*) AS play_count, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms + FROM sessions + GROUP BY day, server_id + WITH NO DATA + `); + + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats_summary + WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 day', started_at) AS day, + COUNT(DISTINCT COALESCE(reference_id, id)) AS play_count, + COUNT(DISTINCT server_user_id) AS user_count, + COUNT(DISTINCT server_id) AS server_count, + SUM(COALESCE(duration_ms, 0)) AS total_duration_ms, + AVG(COALESCE(duration_ms, 0))::bigint AS avg_duration_ms + FROM sessions + GROUP BY day + WITH NO DATA + `); + + // Hourly concurrent streams (used by /concurrent endpoint) + await db.execute(sql` + CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_concurrent_streams + WITH (timescaledb.continuous) AS + SELECT + time_bucket('1 hour', started_at) AS hour, + server_id, + COUNT(*) AS stream_count + FROM sessions + WHERE state IN ('playing', 'paused') + GROUP BY hour, server_id + WITH NO DATA + `); + } +} + +/** + * Set up refresh policies for continuous aggregates + * Refreshes every 5 minutes with 1 hour lag for real-time dashboard + */ +async function setupRefreshPolicies(): Promise { + await db.execute(sql` + SELECT add_continuous_aggregate_policy('daily_plays_by_user', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '5 minutes', + if_not_exists => true + ) + `); + + await db.execute(sql` + SELECT add_continuous_aggregate_policy('daily_plays_by_server', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '5 minutes', + if_not_exists => true + ) + `); + + await db.execute(sql` + SELECT add_continuous_aggregate_policy('daily_stats_summary', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '5 minutes', + if_not_exists => true + ) + `); + + await db.execute(sql` + SELECT add_continuous_aggregate_policy('hourly_concurrent_streams', + start_offset => INTERVAL '1 day', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '5 minutes', + if_not_exists => true + ) + `); +} + +/** + * Enable compression on sessions hypertable + */ +async function enableCompression(): Promise { + // Enable compression settings + await db.execute(sql` + ALTER TABLE sessions SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'server_user_id, server_id' + ) + `); + + // Add compression policy (compress chunks older than 7 days) + await db.execute(sql` + SELECT add_compression_policy('sessions', INTERVAL '7 days', if_not_exists => true) + `); +} + +/** + * Manually refresh all continuous aggregates + * Call this after bulk data imports (e.g., Tautulli import) to make the data immediately available + */ +export async function refreshAggregates(): Promise { + const hasExtension = await isTimescaleInstalled(); + if (!hasExtension) return; + + const aggregates = await getContinuousAggregates(); + + for (const aggregate of aggregates) { + try { + // Refresh the entire aggregate (no time bounds = full refresh) + await db.execute( + sql.raw(`CALL refresh_continuous_aggregate('${aggregate}', NULL, NULL)`) + ); + } catch (err) { + // Log but don't fail - aggregate might not have data yet + console.warn(`Failed to refresh aggregate ${aggregate}:`, err); + } + } +} + +/** + * Get current TimescaleDB status + */ +export async function getTimescaleStatus(): Promise { + const extensionInstalled = await isTimescaleInstalled(); + + if (!extensionInstalled) { + return { + extensionInstalled: false, + sessionsIsHypertable: false, + compressionEnabled: false, + continuousAggregates: [], + chunkCount: 0, + }; + } + + return { + extensionInstalled: true, + sessionsIsHypertable: await isSessionsHypertable(), + compressionEnabled: await isCompressionEnabled(), + continuousAggregates: await getContinuousAggregates(), + chunkCount: await getChunkCount(), + }; +} + +/** + * Initialize TimescaleDB for the sessions table + * + * This function is idempotent and safe to run on: + * - Fresh installs (sets everything up) + * - Existing installs with TimescaleDB already configured (no-op) + * - Partially configured installs (completes setup) + * - Installs without TimescaleDB extension (graceful skip) + */ +export async function initTimescaleDB(): Promise<{ + success: boolean; + status: TimescaleStatus; + actions: string[]; +}> { + const actions: string[] = []; + + // Check if TimescaleDB extension is available + const hasExtension = await isTimescaleInstalled(); + if (!hasExtension) { + return { + success: true, // Not a failure - just no TimescaleDB + status: { + extensionInstalled: false, + sessionsIsHypertable: false, + compressionEnabled: false, + continuousAggregates: [], + chunkCount: 0, + }, + actions: ['TimescaleDB extension not installed - skipping setup'], + }; + } + + actions.push('TimescaleDB extension found'); + + // Enable TimescaleDB Toolkit for HyperLogLog (approximate distinct counts) + // Check if available first to avoid noisy PostgreSQL errors in logs + const toolkitAvailable = await isToolkitAvailableOnSystem(); + if (toolkitAvailable) { + const toolkitInstalled = await isToolkitInstalled(); + if (!toolkitInstalled) { + await db.execute(sql`CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit`); + actions.push('TimescaleDB Toolkit extension enabled'); + } else { + actions.push('TimescaleDB Toolkit extension already enabled'); + } + } else { + actions.push('TimescaleDB Toolkit not available (optional - using standard aggregates)'); + } + + // Check if sessions is already a hypertable + const isHypertable = await isSessionsHypertable(); + if (!isHypertable) { + await convertToHypertable(); + actions.push('Converted sessions table to hypertable'); + } else { + actions.push('Sessions already a hypertable'); + } + + // Check and create continuous aggregates + const existingAggregates = await getContinuousAggregates(); + const expectedAggregates = [ + 'daily_plays_by_user', + 'daily_plays_by_server', + 'daily_stats_summary', + 'hourly_concurrent_streams', + ]; + + const missingAggregates = expectedAggregates.filter( + (agg) => !existingAggregates.includes(agg) + ); + + if (missingAggregates.length > 0) { + await createContinuousAggregates(); + await setupRefreshPolicies(); + actions.push(`Created continuous aggregates: ${missingAggregates.join(', ')}`); + } else { + actions.push('All continuous aggregates exist'); + } + + // Check and enable compression + const hasCompression = await isCompressionEnabled(); + if (!hasCompression) { + await enableCompression(); + actions.push('Enabled compression on sessions'); + } else { + actions.push('Compression already enabled'); + } + + // Create partial indexes for optimized filtered queries + try { + await createPartialIndexes(); + actions.push('Created partial indexes (geo, violations, active, transcode)'); + } catch (err) { + console.warn('Failed to create some partial indexes:', err); + actions.push('Partial indexes: some may already exist'); + } + + // Create content and device tracking indexes + try { + await createContentIndexes(); + actions.push('Created content and device tracking indexes'); + } catch (err) { + console.warn('Failed to create some content indexes:', err); + actions.push('Content indexes: some may already exist'); + } + + // Get final status + const status = await getTimescaleStatus(); + + return { + success: true, + status, + actions, + }; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..d94ca5c --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,458 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { config } from 'dotenv'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import sensible from '@fastify/sensible'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import fastifyStatic from '@fastify/static'; +import { existsSync } from 'node:fs'; +import { Redis } from 'ioredis'; +import { API_BASE_PATH, REDIS_KEYS, WS_EVENTS } from '@tracearr/shared'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Project root directory (apps/server/src -> project root) +const PROJECT_ROOT = resolve(__dirname, '../../..'); + +// Load .env from project root +config({ path: resolve(PROJECT_ROOT, '.env') }); + +// GeoIP database path (in project root/data) +const GEOIP_DB_PATH = resolve(PROJECT_ROOT, 'data/GeoLite2-City.mmdb'); + +// Migrations path (relative to compiled output in production, source in dev) +const MIGRATIONS_PATH = resolve(__dirname, '../src/db/migrations'); +import type { ActiveSession, ViolationWithDetails, DashboardStats, TautulliImportProgress } from '@tracearr/shared'; + +import authPlugin from './plugins/auth.js'; +import redisPlugin from './plugins/redis.js'; +import { authRoutes } from './routes/auth/index.js'; +import { setupRoutes } from './routes/setup.js'; +import { serverRoutes } from './routes/servers.js'; +import { userRoutes } from './routes/users/index.js'; +import { sessionRoutes } from './routes/sessions.js'; +import { ruleRoutes } from './routes/rules.js'; +import { violationRoutes } from './routes/violations.js'; +import { statsRoutes } from './routes/stats/index.js'; +import { settingsRoutes } from './routes/settings.js'; +import { importRoutes } from './routes/import.js'; +import { imageRoutes } from './routes/images.js'; +import { debugRoutes } from './routes/debug.js'; +import { mobileRoutes } from './routes/mobile.js'; +import { notificationPreferencesRoutes } from './routes/notificationPreferences.js'; +import { channelRoutingRoutes } from './routes/channelRouting.js'; +import { getPollerSettings, getNetworkSettings } from './routes/settings.js'; +import { initializeEncryption, migrateToken, looksEncrypted } from './utils/crypto.js'; +import { geoipService } from './services/geoip.js'; +import { createCacheService, createPubSubService } from './services/cache.js'; +import { initializePoller, startPoller, stopPoller } from './jobs/poller/index.js'; +import { sseManager } from './services/sseManager.js'; +import { initializeSSEProcessor, startSSEProcessor, stopSSEProcessor } from './jobs/sseProcessor.js'; +import { initializeWebSocket, broadcastToSessions } from './websocket/index.js'; +import { + initNotificationQueue, + startNotificationWorker, + shutdownNotificationQueue, +} from './jobs/notificationQueue.js'; +import { + initImportQueue, + startImportWorker, + shutdownImportQueue, +} from './jobs/importQueue.js'; +import { initPushRateLimiter } from './services/pushRateLimiter.js'; +import { db, runMigrations } from './db/client.js'; +import { initTimescaleDB, getTimescaleStatus } from './db/timescale.js'; +import { sql, eq } from 'drizzle-orm'; +import { servers } from './db/schema.js'; + +const PORT = parseInt(process.env.PORT ?? '3000', 10); +const HOST = process.env.HOST ?? '0.0.0.0'; + +async function buildApp(options: { trustProxy?: boolean } = {}) { + const app = Fastify({ + logger: { + level: process.env.LOG_LEVEL ?? 'info', + transport: + process.env.NODE_ENV === 'development' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, + }, + // Trust proxy if enabled in settings or via env var + // This respects X-Forwarded-For, X-Forwarded-Proto headers from reverse proxies + trustProxy: options.trustProxy ?? process.env.TRUST_PROXY === 'true', + }); + + // Run database migrations + try { + app.log.info('Running database migrations...'); + await runMigrations(MIGRATIONS_PATH); + app.log.info('Database migrations complete'); + } catch (err) { + app.log.error({ err }, 'Failed to run database migrations'); + throw err; + } + + // Initialize TimescaleDB features (hypertable, compression, aggregates) + try { + app.log.info('Initializing TimescaleDB...'); + const tsResult = await initTimescaleDB(); + for (const action of tsResult.actions) { + app.log.info(` TimescaleDB: ${action}`); + } + if (tsResult.status.sessionsIsHypertable) { + app.log.info( + `TimescaleDB ready: ${tsResult.status.chunkCount} chunks, ` + + `compression=${tsResult.status.compressionEnabled}, ` + + `aggregates=${tsResult.status.continuousAggregates.length}` + ); + } else if (!tsResult.status.extensionInstalled) { + app.log.warn('TimescaleDB extension not installed - running without time-series optimization'); + } + } catch (err) { + app.log.error({ err }, 'Failed to initialize TimescaleDB - continuing without optimization'); + // Don't throw - app can still work without TimescaleDB features + } + + // Initialize encryption (optional - only needed for migrating existing encrypted tokens) + const encryptionAvailable = initializeEncryption(); + if (encryptionAvailable) { + app.log.info('Encryption key available for token migration'); + } + + // Migrate any encrypted tokens to plain text + try { + const allServers = await db.select({ id: servers.id, token: servers.token }).from(servers); + let migrated = 0; + let failed = 0; + + for (const server of allServers) { + if (looksEncrypted(server.token)) { + const result = migrateToken(server.token); + if (result.wasEncrypted) { + await db.update(servers).set({ token: result.plainText }).where(eq(servers.id, server.id)); + migrated++; + } else { + // Looks encrypted but couldn't decrypt - always warn regardless of key availability + app.log.warn( + { serverId: server.id, hasEncryptionKey: encryptionAvailable }, + 'Server token appears encrypted but could not be decrypted. ' + + (encryptionAvailable + ? 'The encryption key may not match. ' + : 'No ENCRYPTION_KEY provided. ') + + 'You may need to re-add this server.' + ); + failed++; + } + } + } + + if (migrated > 0) { + app.log.info(`Migrated ${migrated} server token(s) from encrypted to plain text storage`); + } + if (failed > 0) { + app.log.warn( + `${failed} server(s) have tokens that could not be decrypted. ` + + 'These servers will need to be re-added.' + ); + } + } catch (err) { + app.log.error({ err }, 'Failed to migrate encrypted tokens'); + // Don't throw - let the app start, individual servers will fail gracefully + } + + // Initialize GeoIP service (optional - graceful degradation) + await geoipService.initialize(GEOIP_DB_PATH); + if (geoipService.hasDatabase()) { + app.log.info('GeoIP database loaded'); + } else { + app.log.warn('GeoIP database not available - location features disabled'); + } + + // Security plugins - relaxed for HTTP-only deployments + await app.register(helmet, { + contentSecurityPolicy: false, + crossOriginOpenerPolicy: false, + crossOriginEmbedderPolicy: false, + originAgentCluster: false, + }); + await app.register(cors, { + origin: process.env.CORS_ORIGIN || true, + credentials: true, + }); + await app.register(rateLimit, { + max: 1000, + timeWindow: '1 minute', + }); + + // Utility plugins + await app.register(sensible); + await app.register(cookie, { + secret: process.env.COOKIE_SECRET, + }); + + // Redis plugin + await app.register(redisPlugin); + + // Auth plugin (depends on cookie) + await app.register(authPlugin); + + // Create cache and pubsub services + const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'; + const pubSubRedis = new Redis(redisUrl); + const cacheService = createCacheService(app.redis); + const pubSubService = createPubSubService(app.redis, pubSubRedis); + + // Initialize push notification rate limiter (uses Redis for sliding window counters) + initPushRateLimiter(app.redis); + app.log.info('Push notification rate limiter initialized'); + + // Initialize notification queue (uses Redis for job storage) + try { + initNotificationQueue(redisUrl); + startNotificationWorker(); + app.log.info('Notification queue initialized'); + } catch (err) { + app.log.error({ err }, 'Failed to initialize notification queue'); + // Don't throw - notifications are non-critical + } + + // Initialize import queue (uses Redis for job storage) + try { + initImportQueue(redisUrl); + startImportWorker(); + app.log.info('Import queue initialized'); + } catch (err) { + app.log.error({ err }, 'Failed to initialize import queue'); + // Don't throw - imports can fall back to direct execution + } + + // Initialize poller with cache services and Redis client + initializePoller(cacheService, pubSubService, app.redis); + + // Initialize SSE manager and processor for real-time Plex updates + try { + await sseManager.initialize(cacheService, pubSubService); + initializeSSEProcessor(cacheService, pubSubService); + app.log.info('SSE manager initialized'); + } catch (err) { + app.log.error({ err }, 'Failed to initialize SSE manager'); + // Don't throw - SSE is optional, fallback to polling + } + + // Cleanup pub/sub redis, notification queue, and import queue on close + app.addHook('onClose', async () => { + await pubSubRedis.quit(); + stopPoller(); + await sseManager.stop(); + stopSSEProcessor(); + await shutdownNotificationQueue(); + await shutdownImportQueue(); + }); + + // Health check endpoint + app.get('/health', async () => { + let dbHealthy = false; + let redisHealthy = false; + + // Check database + try { + await db.execute(sql`SELECT 1`); + dbHealthy = true; + } catch { + dbHealthy = false; + } + + // Check Redis + try { + const pong = await app.redis.ping(); + redisHealthy = pong === 'PONG'; + } catch { + redisHealthy = false; + } + + // Check TimescaleDB status + let timescale = null; + try { + const tsStatus = await getTimescaleStatus(); + timescale = { + installed: tsStatus.extensionInstalled, + hypertable: tsStatus.sessionsIsHypertable, + compression: tsStatus.compressionEnabled, + aggregates: tsStatus.continuousAggregates.length, + chunks: tsStatus.chunkCount, + }; + } catch { + timescale = { installed: false, hypertable: false, compression: false, aggregates: 0, chunks: 0 }; + } + + return { + status: dbHealthy && redisHealthy ? 'ok' : 'degraded', + db: dbHealthy, + redis: redisHealthy, + geoip: geoipService.hasDatabase(), + timescale, + }; + }); + + // API routes + await app.register(setupRoutes, { prefix: `${API_BASE_PATH}/setup` }); + await app.register(authRoutes, { prefix: `${API_BASE_PATH}/auth` }); + await app.register(serverRoutes, { prefix: `${API_BASE_PATH}/servers` }); + await app.register(userRoutes, { prefix: `${API_BASE_PATH}/users` }); + await app.register(sessionRoutes, { prefix: `${API_BASE_PATH}/sessions` }); + await app.register(ruleRoutes, { prefix: `${API_BASE_PATH}/rules` }); + await app.register(violationRoutes, { prefix: `${API_BASE_PATH}/violations` }); + await app.register(statsRoutes, { prefix: `${API_BASE_PATH}/stats` }); + await app.register(settingsRoutes, { prefix: `${API_BASE_PATH}/settings` }); + await app.register(channelRoutingRoutes, { prefix: `${API_BASE_PATH}/settings/notifications` }); + await app.register(importRoutes, { prefix: `${API_BASE_PATH}/import` }); + await app.register(imageRoutes, { prefix: `${API_BASE_PATH}/images` }); + await app.register(debugRoutes, { prefix: `${API_BASE_PATH}/debug` }); + await app.register(mobileRoutes, { prefix: `${API_BASE_PATH}/mobile` }); + await app.register(notificationPreferencesRoutes, { prefix: `${API_BASE_PATH}/notifications` }); + + // Serve static frontend in production + const webDistPath = resolve(PROJECT_ROOT, 'apps/web/dist'); + if (process.env.NODE_ENV === 'production' && existsSync(webDistPath)) { + await app.register(fastifyStatic, { + root: webDistPath, + prefix: '/', + }); + + // SPA fallback - serve index.html for all non-API routes + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith('/api/') || request.url === '/health') { + return reply.code(404).send({ error: 'Not Found' }); + } + return reply.sendFile('index.html'); + }); + + app.log.info('Static file serving enabled for production'); + } + + return app; +} + +async function start() { + try { + const app = await buildApp(); + + // Handle graceful shutdown + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + for (const signal of signals) { + process.on(signal, () => { + app.log.info(`Received ${signal}, shutting down gracefully...`); + stopPoller(); + void shutdownNotificationQueue(); + void shutdownImportQueue(); + void app.close().then(() => process.exit(0)); + }); + } + + await app.listen({ port: PORT, host: HOST }); + app.log.info(`Server running at http://${HOST}:${PORT}`); + + // Initialize WebSocket server using Fastify's underlying HTTP server + const httpServer = app.server; + initializeWebSocket(httpServer); + app.log.info('WebSocket server initialized'); + + // Set up Redis pub/sub to forward events to WebSocket clients + const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'; + const wsSubscriber = new Redis(redisUrl); + + void wsSubscriber.subscribe(REDIS_KEYS.PUBSUB_EVENTS, (err) => { + if (err) { + app.log.error({ err }, 'Failed to subscribe to pub/sub channel'); + } else { + app.log.info('Subscribed to pub/sub channel for WebSocket events'); + } + }); + + wsSubscriber.on('message', (_channel: string, message: string) => { + try { + const { event, data } = JSON.parse(message) as { + event: string; + data: unknown; + timestamp: number; + }; + + // Forward events to WebSocket clients + switch (event) { + case WS_EVENTS.SESSION_STARTED: + broadcastToSessions('session:started', data as ActiveSession); + break; + case WS_EVENTS.SESSION_STOPPED: + broadcastToSessions('session:stopped', data as string); + break; + case WS_EVENTS.SESSION_UPDATED: + broadcastToSessions('session:updated', data as ActiveSession); + break; + case WS_EVENTS.VIOLATION_NEW: + broadcastToSessions('violation:new', data as ViolationWithDetails); + break; + case WS_EVENTS.STATS_UPDATED: + broadcastToSessions('stats:updated', data as DashboardStats); + break; + case WS_EVENTS.IMPORT_PROGRESS: + broadcastToSessions('import:progress', data as TautulliImportProgress); + break; + default: + // Unknown event, ignore + break; + } + } catch (err) { + app.log.error({ err, message }, 'Failed to process pub/sub message'); + } + }); + + // Handle graceful shutdown for WebSocket subscriber + const cleanupWsSubscriber = () => { + void wsSubscriber.quit(); + }; + process.on('SIGINT', cleanupWsSubscriber); + process.on('SIGTERM', cleanupWsSubscriber); + + // Start session poller after server is listening (uses DB settings) + const pollerSettings = await getPollerSettings(); + if (pollerSettings.enabled) { + startPoller({ enabled: true, intervalMs: pollerSettings.intervalMs }); + } else { + app.log.info('Session poller disabled in settings'); + } + + // Start SSE connections for Plex servers (real-time updates) + try { + startSSEProcessor(); // Subscribe to SSE events + await sseManager.start(); // Start SSE connections + app.log.info('SSE connections started for Plex servers'); + } catch (err) { + app.log.error({ err }, 'Failed to start SSE connections - falling back to polling'); + } + + // Log network settings status + const networkSettings = await getNetworkSettings(); + const envTrustProxy = process.env.TRUST_PROXY === 'true'; + if (networkSettings.trustProxy && !envTrustProxy) { + app.log.warn( + 'Trust proxy is enabled in settings but TRUST_PROXY env var is not set. ' + + 'Set TRUST_PROXY=true and restart for reverse proxy support.' + ); + } + if (networkSettings.externalUrl) { + app.log.info(`External URL configured: ${networkSettings.externalUrl}`); + } + if (networkSettings.basePath) { + app.log.info(`Base path configured: ${networkSettings.basePath}`); + } + } catch (err) { + console.error('Failed to start server:', err); + process.exit(1); + } +} + +void start(); diff --git a/apps/server/src/jobs/__tests__/aggregator.test.ts b/apps/server/src/jobs/__tests__/aggregator.test.ts new file mode 100644 index 0000000..b8e529b --- /dev/null +++ b/apps/server/src/jobs/__tests__/aggregator.test.ts @@ -0,0 +1,255 @@ +/** + * Aggregator Job Tests + * + * Tests the ACTUAL exported functions from aggregator.ts: + * - startAggregator: Start the stats refresh interval + * - stopAggregator: Stop the interval + * - triggerRefresh: Force an immediate refresh + * + * These tests validate: + * - Interval lifecycle (start/stop) + * - Double-start prevention + * - Config merging + * - Disabled state handling + * - Immediate execution on start + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Import ACTUAL production functions - not local duplicates +import { startAggregator, stopAggregator, triggerRefresh } from '../aggregator.js'; + +describe('aggregator', () => { + // Spy on console methods + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + // Always stop the aggregator to clean up any running intervals + stopAggregator(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('startAggregator', () => { + it('should start the aggregator with default config', () => { + startAggregator(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Starting stats aggregator') + ); + }); + + it('should log interval time when starting', () => { + startAggregator({ intervalMs: 30000 }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Starting stats aggregator with 30000ms interval' + ); + }); + + it('should run refreshStats immediately on start', () => { + startAggregator(); + + // First call should be the "Starting..." message + // Second call should be the "Refreshing..." message from immediate run + expect(consoleLogSpy).toHaveBeenCalledWith('Refreshing dashboard statistics...'); + }); + + it('should prevent double start', () => { + startAggregator(); + consoleLogSpy.mockClear(); + + startAggregator(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Aggregator already running'); + // Should only log "already running", not start again + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Starting stats aggregator') + ); + }); + + it('should not start when disabled', () => { + startAggregator({ enabled: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith('Stats aggregator disabled'); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Starting stats aggregator') + ); + }); + + it('should run on interval after start', () => { + startAggregator({ intervalMs: 10000 }); + consoleLogSpy.mockClear(); + + // Advance time by interval + vi.advanceTimersByTime(10000); + + expect(consoleLogSpy).toHaveBeenCalledWith('Refreshing dashboard statistics...'); + }); + + it('should run multiple times on interval', () => { + startAggregator({ intervalMs: 5000 }); + consoleLogSpy.mockClear(); + + // Advance 3 intervals + vi.advanceTimersByTime(5000); + vi.advanceTimersByTime(5000); + vi.advanceTimersByTime(5000); + + // Should have run 3 times + const refreshCalls = consoleLogSpy.mock.calls.filter( + (call: unknown[]) => call[0] === 'Refreshing dashboard statistics...' + ); + expect(refreshCalls).toHaveLength(3); + }); + + it('should merge partial config with defaults', () => { + // Only override intervalMs, enabled should default to true + startAggregator({ intervalMs: 1000 }); + + // Should start (enabled defaults to true) + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Starting stats aggregator with 1000ms interval' + ); + }); + + it('should use default interval when not specified', () => { + startAggregator({}); + + // Default is POLLING_INTERVALS.STATS_REFRESH = 60000 + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Starting stats aggregator with 60000ms interval' + ); + }); + }); + + describe('stopAggregator', () => { + it('should stop the aggregator', () => { + startAggregator(); + consoleLogSpy.mockClear(); + + stopAggregator(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Stats aggregator stopped'); + }); + + it('should allow starting again after stop', () => { + startAggregator(); + stopAggregator(); + consoleLogSpy.mockClear(); + + startAggregator(); + + // Should start fresh, not say "already running" + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Starting stats aggregator') + ); + expect(consoleLogSpy).not.toHaveBeenCalledWith('Aggregator already running'); + }); + + it('should do nothing when not running', () => { + // Don't start first + stopAggregator(); + + // Should not log anything since there's nothing to stop + expect(consoleLogSpy).not.toHaveBeenCalledWith('Stats aggregator stopped'); + }); + + it('should prevent further interval executions', () => { + startAggregator({ intervalMs: 5000 }); + consoleLogSpy.mockClear(); + + stopAggregator(); + consoleLogSpy.mockClear(); + + // Advance time - should not trigger refresh + vi.advanceTimersByTime(5000); + vi.advanceTimersByTime(5000); + + const refreshCalls = consoleLogSpy.mock.calls.filter( + (call: unknown[]) => call[0] === 'Refreshing dashboard statistics...' + ); + expect(refreshCalls).toHaveLength(0); + }); + }); + + describe('triggerRefresh', () => { + it('should trigger immediate refresh', async () => { + await triggerRefresh(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Refreshing dashboard statistics...'); + }); + + it('should work independently of aggregator state', async () => { + // Don't start aggregator + await triggerRefresh(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Refreshing dashboard statistics...'); + }); + + it('should work while aggregator is running', async () => { + startAggregator({ intervalMs: 60000 }); + consoleLogSpy.mockClear(); + + await triggerRefresh(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Refreshing dashboard statistics...'); + }); + + it('should be awaitable', async () => { + const promise = triggerRefresh(); + + // Should be a promise + expect(promise).toBeInstanceOf(Promise); + + // Should resolve without error + await expect(promise).resolves.toBeUndefined(); + }); + }); + + describe('lifecycle scenarios', () => { + it('should handle start-stop-start-stop cycle', () => { + startAggregator(); + stopAggregator(); + startAggregator(); + stopAggregator(); + + // Should have logged "stopped" twice + const stopCalls = consoleLogSpy.mock.calls.filter( + (call: unknown[]) => call[0] === 'Stats aggregator stopped' + ); + expect(stopCalls).toHaveLength(2); + }); + + it('should handle multiple stop calls gracefully', () => { + startAggregator(); + stopAggregator(); + stopAggregator(); + stopAggregator(); + + // Only first stop should log + const stopCalls = consoleLogSpy.mock.calls.filter( + (call: unknown[]) => call[0] === 'Stats aggregator stopped' + ); + expect(stopCalls).toHaveLength(1); + }); + + it('should handle start with disabled then start with enabled', () => { + startAggregator({ enabled: false }); + consoleLogSpy.mockClear(); + + startAggregator({ enabled: true }); + + // Second call should start (since disabled didn't create interval) + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Starting stats aggregator') + ); + }); + }); +}); diff --git a/apps/server/src/jobs/__tests__/cleanupMobileTokens.test.ts b/apps/server/src/jobs/__tests__/cleanupMobileTokens.test.ts new file mode 100644 index 0000000..136c3c0 --- /dev/null +++ b/apps/server/src/jobs/__tests__/cleanupMobileTokens.test.ts @@ -0,0 +1,243 @@ +/** + * Cleanup Mobile Tokens Job Tests + * + * Tests the mobile token cleanup job: + * - Deletes expired unused tokens (older than 1 hour) + * - Deletes used tokens (older than 30 days) + * - Returns count of deleted tokens + * + * Uses mocked database to test cleanup logic in isolation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; + +// Mock the database +vi.mock('../../db/client.js', () => ({ + db: { + delete: vi.fn(), + }, +})); + +// Import after mocking +import { db } from '../../db/client.js'; +import { cleanupMobileTokens } from '../cleanupMobileTokens.js'; + +// Type the mocked db +const mockDb = db as unknown as { + delete: ReturnType; +}; + +// Helper to create a mock delete chain +function mockDeleteChain(expiredResult: { id: string }[], usedResult: { id: string }[]) { + let callCount = 0; + + mockDb.delete.mockImplementation(() => ({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockImplementation(() => { + callCount++; + // First call is for expired tokens, second for used tokens + return callCount === 1 ? expiredResult : usedResult; + }), + }), + })); +} + +describe('cleanupMobileTokens', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('expired unused tokens cleanup', () => { + it('should delete expired unused tokens older than 1 hour', async () => { + const expiredTokens = [ + { id: randomUUID() }, + { id: randomUUID() }, + { id: randomUUID() }, + ]; + mockDeleteChain(expiredTokens, []); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(3); + expect(mockDb.delete).toHaveBeenCalledTimes(2); // Both expired and used queries + }); + + it('should not delete recently created unused tokens', async () => { + // No expired tokens found + mockDeleteChain([], []); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(0); + }); + }); + + describe('used tokens cleanup', () => { + it('should delete used tokens older than 30 days', async () => { + const usedTokens = [ + { id: randomUUID() }, + { id: randomUUID() }, + ]; + mockDeleteChain([], usedTokens); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(2); + }); + + it('should not delete recently used tokens', async () => { + // No old used tokens found + mockDeleteChain([], []); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(0); + }); + }); + + describe('combined cleanup', () => { + it('should delete both expired and used tokens in single run', async () => { + const expiredTokens = [{ id: randomUUID() }, { id: randomUUID() }]; + const usedTokens = [{ id: randomUUID() }, { id: randomUUID() }, { id: randomUUID() }]; + mockDeleteChain(expiredTokens, usedTokens); + + const result = await cleanupMobileTokens(); + + // Total: 2 expired + 3 used = 5 + expect(result.deleted).toBe(5); + }); + + it('should return zero when no tokens need cleanup', async () => { + mockDeleteChain([], []); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(0); + }); + + it('should handle large number of tokens', async () => { + const expiredTokens = Array.from({ length: 100 }, () => ({ id: randomUUID() })); + const usedTokens = Array.from({ length: 50 }, () => ({ id: randomUUID() })); + mockDeleteChain(expiredTokens, usedTokens); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(150); + }); + }); + + describe('database query construction', () => { + it('should call delete on mobileTokens table', async () => { + mockDeleteChain([], []); + + await cleanupMobileTokens(); + + // Should be called twice: once for expired, once for used + expect(mockDb.delete).toHaveBeenCalledTimes(2); + }); + + it('should use where clause with proper conditions', async () => { + const whereMock = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + }); + mockDb.delete.mockReturnValue({ where: whereMock }); + + await cleanupMobileTokens(); + + // Each delete should chain to where + expect(whereMock).toHaveBeenCalledTimes(2); + }); + + it('should return ids from deleted tokens', async () => { + const returningMock = vi.fn() + .mockResolvedValueOnce([{ id: 'expired-1' }]) + .mockResolvedValueOnce([{ id: 'used-1' }, { id: 'used-2' }]); + + mockDb.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: returningMock, + }), + }); + + const result = await cleanupMobileTokens(); + + expect(result.deleted).toBe(3); + expect(returningMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('time boundaries', () => { + it('should calculate 1 hour cutoff correctly', async () => { + // Set current time to noon UTC + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + + let capturedWhereFn: unknown; + mockDb.delete.mockImplementation(() => ({ + where: vi.fn().mockImplementation((condition) => { + if (!capturedWhereFn) { + capturedWhereFn = condition; + } + return { returning: vi.fn().mockResolvedValue([]) }; + }), + })); + + await cleanupMobileTokens(); + + // The first where clause should be for expired tokens + // 1 hour ago from noon = 11:00 AM + expect(capturedWhereFn).toBeDefined(); + }); + + it('should calculate 30 day cutoff correctly', async () => { + // Set current time to Jan 15, 2025 + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + + const whereConditions: unknown[] = []; + mockDb.delete.mockImplementation(() => ({ + where: vi.fn().mockImplementation((condition) => { + whereConditions.push(condition); + return { returning: vi.fn().mockResolvedValue([]) }; + }), + })); + + await cleanupMobileTokens(); + + // Should have captured both where conditions + // First for expired (1 hour), second for used (30 days) + expect(whereConditions).toHaveLength(2); + }); + }); + + describe('error handling', () => { + it('should propagate database errors for expired query', async () => { + mockDb.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockRejectedValue(new Error('Database connection failed')), + }), + }); + + await expect(cleanupMobileTokens()).rejects.toThrow('Database connection failed'); + }); + + it('should propagate database errors for used query', async () => { + const returningMock = vi.fn() + .mockResolvedValueOnce([]) // Expired query succeeds + .mockRejectedValueOnce(new Error('Query timeout')); // Used query fails + + mockDb.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: returningMock, + }), + }); + + await expect(cleanupMobileTokens()).rejects.toThrow('Query timeout'); + }); + }); +}); diff --git a/apps/server/src/jobs/aggregator.ts b/apps/server/src/jobs/aggregator.ts new file mode 100644 index 0000000..1a5a3fb --- /dev/null +++ b/apps/server/src/jobs/aggregator.ts @@ -0,0 +1,78 @@ +/** + * Background job for refreshing dashboard statistics + */ + +import { POLLING_INTERVALS } from '@tracearr/shared'; + +let aggregatorInterval: NodeJS.Timeout | null = null; + +export interface AggregatorConfig { + enabled: boolean; + intervalMs: number; +} + +const defaultConfig: AggregatorConfig = { + enabled: true, + intervalMs: POLLING_INTERVALS.STATS_REFRESH, +}; + +/** + * Refresh dashboard statistics and cache them + */ +async function refreshStats(): Promise { + try { + // 1. Query active stream count + // 2. Query today's play count + // 3. Query watch time for period + // 4. Query violation count for last 24h + // 5. Cache results in Redis with TTL + // 6. Broadcast stats update via WebSocket + + console.log('Refreshing dashboard statistics...'); + } catch (error) { + console.error('Stats aggregation error:', error); + } +} + +/** + * Start the aggregator job + */ +export function startAggregator(config: Partial = {}): void { + const mergedConfig = { ...defaultConfig, ...config }; + + if (!mergedConfig.enabled) { + console.log('Stats aggregator disabled'); + return; + } + + if (aggregatorInterval) { + console.log('Aggregator already running'); + return; + } + + console.log(`Starting stats aggregator with ${mergedConfig.intervalMs}ms interval`); + + // Run immediately on start + void refreshStats(); + + // Then run on interval + aggregatorInterval = setInterval(() => void refreshStats(), mergedConfig.intervalMs); +} + +/** + * Stop the aggregator job + */ +export function stopAggregator(): void { + if (aggregatorInterval) { + clearInterval(aggregatorInterval); + aggregatorInterval = null; + console.log('Stats aggregator stopped'); + } +} + +/** + * Force an immediate stats refresh + */ +export async function triggerRefresh(): Promise { + await refreshStats(); +} diff --git a/apps/server/src/jobs/cleanupMobileTokens.ts b/apps/server/src/jobs/cleanupMobileTokens.ts new file mode 100644 index 0000000..481c704 --- /dev/null +++ b/apps/server/src/jobs/cleanupMobileTokens.ts @@ -0,0 +1,40 @@ +/** + * Cleanup job for expired/used mobile pairing tokens + * + * Run via cron or BullMQ scheduler to periodically clean up: + * - Expired unused tokens (older than 1 hour) + * - Used tokens (older than 30 days) + */ + +import { lt, and, isNull, isNotNull } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { mobileTokens } from '../db/schema.js'; + +export async function cleanupMobileTokens(): Promise<{ deleted: number }> { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + // Delete expired unused tokens older than 1 hour + const expiredResult = await db + .delete(mobileTokens) + .where( + and( + lt(mobileTokens.expiresAt, oneHourAgo), + isNull(mobileTokens.usedAt) + ) + ) + .returning({ id: mobileTokens.id }); + + // Delete used tokens older than 30 days + const usedResult = await db + .delete(mobileTokens) + .where( + and( + isNotNull(mobileTokens.usedAt), + lt(mobileTokens.usedAt, thirtyDaysAgo) + ) + ) + .returning({ id: mobileTokens.id }); + + return { deleted: expiredResult.length + usedResult.length }; +} diff --git a/apps/server/src/jobs/importQueue.ts b/apps/server/src/jobs/importQueue.ts new file mode 100644 index 0000000..c77feab --- /dev/null +++ b/apps/server/src/jobs/importQueue.ts @@ -0,0 +1,377 @@ +/** + * Import Queue - BullMQ-based async import processing + * + * Provides reliable, resumable Tautulli import with: + * - Restart resilience (job state persisted in Redis) + * - Cancellation support + * - Progress tracking via WebSocket + * - Checkpoint/resume on failure + */ + +import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; +import type { TautulliImportProgress, TautulliImportResult } from '@tracearr/shared'; +import { TautulliService } from '../services/tautulli.js'; +import { getPubSubService } from '../services/cache.js'; + +// Job data types +export interface ImportJobData { + type: 'tautulli'; + serverId: string; + userId: string; // Audit trail - who initiated the import + checkpoint?: number; // Resume from this page (for future use) +} + +export type ImportJobResult = TautulliImportResult; + +// Queue configuration +const QUEUE_NAME = 'imports'; +const DLQ_NAME = 'imports-dlq'; + +// Connection and instances +let connectionOptions: ConnectionOptions | null = null; +let importQueue: Queue | null = null; +let importWorker: Worker | null = null; +let dlqQueue: Queue | null = null; + +/** + * Initialize the import queue with Redis connection + */ +export function initImportQueue(redisUrl: string): void { + if (importQueue) { + console.log('Import queue already initialized'); + return; + } + + connectionOptions = { url: redisUrl }; + + importQueue = new Queue(QUEUE_NAME, { + connection: connectionOptions, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 60000, // 1min, 2min, 4min between retries + }, + // Note: Job timeout is set per-worker, not in defaultJobOptions + removeOnComplete: { + count: 100, // Keep last 100 completed imports + age: 7 * 24 * 60 * 60, // 7 days + }, + removeOnFail: false, // Keep failed jobs for investigation + }, + }); + + dlqQueue = new Queue(DLQ_NAME, { + connection: connectionOptions, + defaultJobOptions: { + removeOnComplete: false, + removeOnFail: false, + }, + }); + + console.log('Import queue initialized'); +} + +/** + * Start the import worker to process queued jobs + */ +export function startImportWorker(): void { + if (!connectionOptions) { + throw new Error('Import queue not initialized. Call initImportQueue first.'); + } + + if (importWorker) { + console.log('Import worker already running'); + return; + } + + importWorker = new Worker( + QUEUE_NAME, + async (job: Job) => { + const startTime = Date.now(); + console.log(`[Import] Starting job ${job.id} for server ${job.data.serverId}`); + + try { + const result = await processImportJob(job); + const duration = Math.round((Date.now() - startTime) / 1000); + console.log(`[Import] Job ${job.id} completed in ${duration}s:`, result); + return result; + } catch (error) { + const duration = Math.round((Date.now() - startTime) / 1000); + console.error(`[Import] Job ${job.id} failed after ${duration}s:`, error); + throw error; + } + }, + { + connection: connectionOptions, + concurrency: 1, // Only 1 import at a time per worker + // Large imports (300k+ records) can take hours - extend lock to prevent stalled job detection + lockDuration: 5 * 60 * 1000, // 5 minutes (default is 30s) + stalledInterval: 5 * 60 * 1000, // Check for stalled jobs every 5 minutes + limiter: { + max: 1, + duration: 60000, // Max 1 new import per minute (prevents spam) + }, + } + ); + + // Handle job failures - notify frontend and move to DLQ if retries exhausted + importWorker.on('failed', (job, error) => { + if (!job) return; + + // Always notify frontend of failure + const pubSubService = getPubSubService(); + if (pubSubService) { + void pubSubService.publish('import:progress', { + status: 'error', + totalRecords: 0, + fetchedRecords: 0, + processedRecords: 0, + importedRecords: 0, + updatedRecords: 0, + skippedRecords: 0, + duplicateRecords: 0, + unknownUserRecords: 0, + activeSessionRecords: 0, + errorRecords: 0, + currentPage: 0, + totalPages: 0, + message: `Import failed: ${error?.message || 'Unknown error'}`, + jobId: job.id, + }); + } + + if (job.attemptsMade >= (job.opts.attempts || 3)) { + console.error(`[Import] Job ${job.id} exhausted retries, moving to DLQ:`, error); + if (dlqQueue) { + void dlqQueue.add(`dlq-${job.data.type}`, job.data, { + jobId: `dlq-${job.id}`, + }); + } + } + }); + + importWorker.on('error', (error) => { + console.error('[Import] Worker error:', error); + }); + + console.log('Import worker started'); +} + +/** + * Process a single import job + */ +async function processImportJob(job: Job): Promise { + const { serverId } = job.data; + const pubSubService = getPubSubService(); + + // Progress callback to update job and publish to WebSocket + const onProgress = async (progress: TautulliImportProgress) => { + // Update BullMQ job progress (0-100) + const percent = + progress.totalRecords > 0 + ? Math.round((progress.processedRecords / progress.totalRecords) * 100) + : 0; + await job.updateProgress(percent); + + // Extend lock to prevent stalled job detection during long imports + // This is critical for large imports (300k+ records) that can take hours + try { + await job.extendLock(job.token ?? '', 5 * 60 * 1000); // Extend by 5 minutes + } catch { + // Lock extension can fail if job was already moved to another state + console.warn(`[Import] Failed to extend lock for job ${job.id}`); + } + + // Publish to WebSocket for UI + if (pubSubService) { + await pubSubService.publish('import:progress', { + ...progress, + jobId: job.id, + }); + } + }; + + // Run the actual import with progress callback + const result = await TautulliService.importHistory(serverId, pubSubService ?? undefined, onProgress); + + // Publish final result (note: TautulliService already publishes final progress, + // but this is a fallback to ensure frontend receives completion notification) + if (pubSubService) { + const total = result.imported + result.updated + result.skipped + result.errors; + await pubSubService.publish('import:progress', { + status: result.success ? 'complete' : 'error', + totalRecords: total, + fetchedRecords: total, + processedRecords: total, + importedRecords: result.imported, + updatedRecords: result.updated, + skippedRecords: result.skipped, + duplicateRecords: 0, // Not available in result + unknownUserRecords: 0, // Not available in result + activeSessionRecords: 0, // Not available in result + errorRecords: result.errors, + currentPage: 0, + totalPages: 0, + message: result.message, + jobId: job.id, + }); + } + + return result; +} + +/** + * Get active import job for a server (if any) + */ +export async function getActiveImportForServer(serverId: string): Promise { + if (!importQueue) { + return null; + } + + const activeJobs = await importQueue.getJobs(['active', 'waiting', 'delayed']); + const existingJob = activeJobs.find((j) => j.data.serverId === serverId); + + return existingJob?.id ?? null; +} + +/** + * Enqueue a new import job + */ +export async function enqueueImport(serverId: string, userId: string): Promise { + if (!importQueue) { + throw new Error('Import queue not initialized'); + } + + // Check for existing active import for this server + const existingJobId = await getActiveImportForServer(serverId); + + if (existingJobId) { + throw new Error(`Import already in progress for server ${serverId} (job ${existingJobId})`); + } + + const job = await importQueue.add('tautulli-import', { + type: 'tautulli', + serverId, + userId, + }); + + const jobId = job.id ?? `unknown-${Date.now()}`; + console.log(`[Import] Enqueued job ${jobId} for server ${serverId}`); + return jobId; +} + +/** + * Get import job status + */ +export async function getImportStatus(jobId: string): Promise<{ + jobId: string; + state: string; + progress: number | object | null; + result?: ImportJobResult; + failedReason?: string; + createdAt?: number; + finishedAt?: number; +} | null> { + if (!importQueue) { + return null; + } + + const job = await importQueue.getJob(jobId); + if (!job) { + return null; + } + + const state = await job.getState(); + + // job.progress can be number, object, or undefined + const progress = job.progress; + + return { + jobId: job.id ?? jobId, // Fallback to input jobId if somehow null + state, + progress: typeof progress === 'number' || typeof progress === 'object' ? progress : null, + result: job.returnvalue as ImportJobResult | undefined, + failedReason: job.failedReason, + createdAt: job.timestamp, + finishedAt: job.finishedOn, + }; +} + +/** + * Cancel an import job + */ +export async function cancelImport(jobId: string): Promise { + if (!importQueue) { + return false; + } + + const job = await importQueue.getJob(jobId); + if (!job) { + return false; + } + + const state = await job.getState(); + + // Can only cancel waiting/delayed jobs + // Active jobs need worker-level cancellation (not implemented in Phase 1) + if (state === 'waiting' || state === 'delayed') { + await job.remove(); + console.log(`[Import] Cancelled job ${jobId}`); + return true; + } + + console.log(`[Import] Cannot cancel job ${jobId} in state ${state}`); + return false; +} + +/** + * Get queue statistics + */ +export async function getImportQueueStats(): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + dlqSize: number; +} | null> { + if (!importQueue || !dlqQueue) { + return null; + } + + const [waiting, active, completed, failed, delayed, dlqWaiting] = await Promise.all([ + importQueue.getWaitingCount(), + importQueue.getActiveCount(), + importQueue.getCompletedCount(), + importQueue.getFailedCount(), + importQueue.getDelayedCount(), + dlqQueue.getWaitingCount(), + ]); + + return { waiting, active, completed, failed, delayed, dlqSize: dlqWaiting }; +} + +/** + * Gracefully shutdown + */ +export async function shutdownImportQueue(): Promise { + console.log('Shutting down import queue...'); + + if (importWorker) { + await importWorker.close(); + importWorker = null; + } + + if (importQueue) { + await importQueue.close(); + importQueue = null; + } + + if (dlqQueue) { + await dlqQueue.close(); + dlqQueue = null; + } + + console.log('Import queue shutdown complete'); +} diff --git a/apps/server/src/jobs/notificationQueue.ts b/apps/server/src/jobs/notificationQueue.ts new file mode 100644 index 0000000..8ae6e61 --- /dev/null +++ b/apps/server/src/jobs/notificationQueue.ts @@ -0,0 +1,355 @@ +/** + * Notification Queue - BullMQ-based async notification dispatch + * + * Provides reliable, retryable notification delivery with dead letter support. + * All notifications are enqueued here and processed asynchronously by workers. + */ + +import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; +import type { ViolationWithDetails, ActiveSession, NotificationEventType } from '@tracearr/shared'; +import { notificationService } from '../services/notify.js'; +import { pushNotificationService } from '../services/pushNotification.js'; +import { getNotificationSettings } from '../routes/settings.js'; +import { getChannelRouting } from '../routes/channelRouting.js'; + +/** + * Map job types to notification event types for routing lookup + */ +const JOB_TYPE_TO_EVENT_TYPE: Record = { + violation: 'violation_detected', + session_started: 'stream_started', + session_stopped: 'stream_stopped', + server_down: 'server_down', + server_up: 'server_up', +}; + +// Job type discriminated union for type-safe job handling +export type NotificationJobData = + | { type: 'violation'; payload: ViolationWithDetails } + | { type: 'session_started'; payload: ActiveSession } + | { type: 'session_stopped'; payload: ActiveSession } + | { type: 'server_down'; payload: { serverName: string; serverId: string } } + | { type: 'server_up'; payload: { serverName: string; serverId: string } }; + +// Queue name constant +const QUEUE_NAME = 'notifications'; + +// Dead letter queue name for failed jobs that exceed retry attempts +const DLQ_NAME = 'notifications-dlq'; + +// Connection options (will be set during initialization) +let connectionOptions: ConnectionOptions | null = null; + +// Queue and worker instances +let notificationQueue: Queue | null = null; +let notificationWorker: Worker | null = null; +let dlqQueue: Queue | null = null; + +/** + * Initialize the notification queue with Redis connection + */ +export function initNotificationQueue(redisUrl: string): void { + if (notificationQueue) { + console.log('Notification queue already initialized'); + return; + } + + connectionOptions = { url: redisUrl }; + + // Create the main notification queue + notificationQueue = new Queue(QUEUE_NAME, { + connection: connectionOptions, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, // 1s, 2s, 4s + }, + removeOnComplete: { + count: 1000, // Keep last 1000 completed jobs for debugging + age: 24 * 60 * 60, // Remove completed jobs older than 24h + }, + removeOnFail: { + count: 5000, // Keep more failed jobs for analysis + age: 7 * 24 * 60 * 60, // Keep failed jobs for 7 days + }, + }, + }); + + // Create dead letter queue for jobs that fail all retries + dlqQueue = new Queue(DLQ_NAME, { + connection: connectionOptions, + defaultJobOptions: { + removeOnComplete: false, // Never auto-remove from DLQ + removeOnFail: false, + }, + }); + + console.log('Notification queue initialized'); +} + +/** + * Start the notification worker to process queued jobs + */ +export function startNotificationWorker(): void { + if (!connectionOptions) { + throw new Error('Notification queue not initialized. Call initNotificationQueue first.'); + } + + if (notificationWorker) { + console.log('Notification worker already running'); + return; + } + + notificationWorker = new Worker( + QUEUE_NAME, + async (job: Job) => { + const startTime = Date.now(); + + try { + await processNotificationJob(job); + + const duration = Date.now() - startTime; + console.log( + `Notification job ${job.id} (${job.data.type}) processed in ${duration}ms` + ); + } catch (error) { + const duration = Date.now() - startTime; + console.error( + `Notification job ${job.id} (${job.data.type}) failed after ${duration}ms:`, + error + ); + throw error; // Re-throw to trigger retry + } + }, + { + connection: connectionOptions, + concurrency: 5, // Process up to 5 notifications in parallel + limiter: { + max: 30, // Max 30 jobs per duration + duration: 1000, // Per second (rate limit for external services) + }, + } + ); + + // Handle failed jobs that exceed retry attempts - move to DLQ + notificationWorker.on('failed', (job, error) => { + if (job && job.attemptsMade >= (job.opts.attempts || 3)) { + console.error( + `Notification job ${job.id} (${job.data.type}) exhausted retries, moving to DLQ:`, + error + ); + + // Move to dead letter queue for manual investigation + if (dlqQueue) { + void dlqQueue.add(`dlq-${job.data.type}`, job.data, { + jobId: `dlq-${job.id}`, + }); + } + } + }); + + notificationWorker.on('error', (error) => { + console.error('Notification worker error:', error); + }); + + console.log('Notification worker started'); +} + +/** + * Process a single notification job + */ +async function processNotificationJob(job: Job): Promise { + const { type, payload } = job.data; + + // Load current settings and channel routing for each job + // (settings/routing may change between enqueue and process) + const settings = await getNotificationSettings(); + const eventType = JOB_TYPE_TO_EVENT_TYPE[type]; + const routing = await getChannelRouting(eventType); + + // Build settings object with routing-aware channel enablement + // The routing config controls which channels receive notifications + const effectiveSettings = { + // Only include webhook URLs if routing allows + discordWebhookUrl: routing.discordEnabled ? settings.discordWebhookUrl : null, + customWebhookUrl: routing.webhookEnabled ? settings.customWebhookUrl : null, + webhookFormat: settings.webhookFormat, + ntfyTopic: settings.ntfyTopic, + // Fill in defaults for other Settings fields + allowGuestAccess: false, + unitSystem: settings.unitSystem ?? 'metric' as const, // Display preference for units + pollerEnabled: true, + pollerIntervalMs: 15000, + tautulliUrl: null, + tautulliApiKey: null, + externalUrl: null, + basePath: '', + trustProxy: false, + mobileEnabled: settings.mobileEnabled ?? false, + primaryAuthMethod: 'local' as const, // Not used in notifications, but required by Settings type + }; + + switch (type) { + case 'violation': + // Send to Discord/webhooks (if routing allows) + if (routing.discordEnabled || routing.webhookEnabled) { + await notificationService.notifyViolation(payload, effectiveSettings); + } + // Send push notification to mobile devices (if routing allows) + if (routing.pushEnabled) { + await pushNotificationService.notifyViolation(payload); + } + break; + + case 'session_started': + // Send to Discord/webhooks (if routing allows) + if (routing.discordEnabled || routing.webhookEnabled) { + await notificationService.notifySessionStarted(payload, effectiveSettings); + } + // Send push notification to mobile devices (if routing allows) + if (routing.pushEnabled) { + await pushNotificationService.notifySessionStarted(payload); + } + break; + + case 'session_stopped': + // Send to Discord/webhooks (if routing allows) + if (routing.discordEnabled || routing.webhookEnabled) { + await notificationService.notifySessionStopped(payload, effectiveSettings); + } + // Send push notification to mobile devices (if routing allows) + if (routing.pushEnabled) { + await pushNotificationService.notifySessionStopped(payload); + } + break; + + case 'server_down': + // Send to Discord/webhooks (if routing allows) + if (routing.discordEnabled || routing.webhookEnabled) { + await notificationService.notifyServerDown(payload.serverName, effectiveSettings); + } + // Send push notification to mobile devices (if routing allows) + if (routing.pushEnabled) { + await pushNotificationService.notifyServerDown(payload.serverName, payload.serverId); + } + break; + + case 'server_up': + // Send to Discord/webhooks (if routing allows) + if (routing.discordEnabled || routing.webhookEnabled) { + await notificationService.notifyServerUp(payload.serverName, effectiveSettings); + } + // Send push notification to mobile devices (if routing allows) + if (routing.pushEnabled) { + await pushNotificationService.notifyServerUp(payload.serverName, payload.serverId); + } + break; + + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = type; + throw new Error(`Unknown notification type: ${_exhaustive}`); + } + } +} + +/** + * Enqueue a notification for async processing + */ +export async function enqueueNotification( + data: NotificationJobData, + options?: { priority?: number; delay?: number } +): Promise { + if (!notificationQueue) { + console.error('Notification queue not initialized, dropping notification:', data.type); + return undefined; + } + + const job = await notificationQueue.add(data.type, data, { + priority: options?.priority, + delay: options?.delay, + }); + + return job.id; +} + +/** + * Get queue statistics for monitoring + */ +export async function getQueueStats(): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + dlqSize: number; +} | null> { + if (!notificationQueue || !dlqQueue) { + return null; + } + + const [waiting, active, completed, failed, delayed, dlqWaiting] = await Promise.all([ + notificationQueue.getWaitingCount(), + notificationQueue.getActiveCount(), + notificationQueue.getCompletedCount(), + notificationQueue.getFailedCount(), + notificationQueue.getDelayedCount(), + dlqQueue.getWaitingCount(), + ]); + + return { + waiting, + active, + completed, + failed, + delayed, + dlqSize: dlqWaiting, + }; +} + +/** + * Gracefully shutdown the notification queue and worker + */ +export async function shutdownNotificationQueue(): Promise { + console.log('Shutting down notification queue...'); + + if (notificationWorker) { + await notificationWorker.close(); + notificationWorker = null; + } + + if (notificationQueue) { + await notificationQueue.close(); + notificationQueue = null; + } + + if (dlqQueue) { + await dlqQueue.close(); + dlqQueue = null; + } + + console.log('Notification queue shutdown complete'); +} + +/** + * Retry all jobs in the dead letter queue + */ +export async function retryDlqJobs(): Promise { + if (!dlqQueue || !notificationQueue) { + return 0; + } + + const jobs = await dlqQueue.getJobs(['waiting', 'delayed']); + let retried = 0; + + for (const job of jobs) { + // Re-enqueue to main queue + await notificationQueue.add(job.data.type, job.data); + await job.remove(); + retried++; + } + + console.log(`Retried ${retried} jobs from DLQ`); + return retried; +} diff --git a/apps/server/src/jobs/poller/__tests__/edgeCases.test.ts b/apps/server/src/jobs/poller/__tests__/edgeCases.test.ts new file mode 100644 index 0000000..563573e --- /dev/null +++ b/apps/server/src/jobs/poller/__tests__/edgeCases.test.ts @@ -0,0 +1,321 @@ +/** + * Session Edge Cases Tests + * + * TDD tests for implementing robust session handling. + * These tests are written BEFORE implementation (RED phase). + * + * HIGH Priority Edge Cases: + * 1. Watch Completion - 85% threshold (configurable per media type) + * 2. Stale Stream Force-Stop - 5 minute timeout + * 3. Minimum Play Time Filtering - 120s default + * 4. Continued Session Threshold - 60s configurable + */ + +import { describe, it, expect } from 'vitest'; +import { + checkWatchCompletion, + shouldGroupWithPreviousSession, +} from '../stateTracker.js'; + +// ============================================================================ +// Watch Completion Detection +// ============================================================================ +// Industry standard uses 85% threshold (configurable per media type) +// Current implementation uses hardcoded 80% +describe('Watch Completion Detection', () => { + describe('85% threshold (industry standard)', () => { + it('should mark as watched at 85% progress', () => { + // Default: MOVIE_WATCHED_PERCENT = 85, TV_WATCHED_PERCENT = 85 + // Currently fails because implementation uses 80% + const progressMs = 85000; // 85% + const totalMs = 100000; + + const result = checkWatchCompletion(progressMs, totalMs); + // This should pass at 85% but current implementation requires only 80% + expect(result).toBe(true); + }); + + it('should NOT mark as watched at 84% progress', () => { + const progressMs = 84000; // 84% + const totalMs = 100000; + + const result = checkWatchCompletion(progressMs, totalMs); + // With 85% threshold, 84% should NOT be watched + // Current implementation incorrectly marks this as watched (80% threshold) + expect(result).toBe(false); + }); + + it('should NOT mark as watched at exactly 80% (below threshold)', () => { + const progressMs = 80000; // 80% + const totalMs = 100000; + + const result = checkWatchCompletion(progressMs, totalMs); + // With 85% threshold, 80% is NOT watched + // Current implementation incorrectly marks this as watched + expect(result).toBe(false); + }); + }); + + describe('configurable threshold per media type', () => { + it('should accept custom threshold for movies', () => { + const progressMs = 90000; + const totalMs = 100000; + const customThreshold = 0.90; // 90% for some users + + // checkWatchCompletion should accept optional threshold parameter + // Currently it doesn't - this test should fail + const result = checkWatchCompletion(progressMs, totalMs, customThreshold); + expect(result).toBe(true); + + // 89% should not pass with 90% threshold + const result2 = checkWatchCompletion(89000, 100000, customThreshold); + expect(result2).toBe(false); + }); + + it('should accept custom threshold for TV episodes', () => { + const progressMs = 85000; + const totalMs = 100000; + const tvThreshold = 0.85; + + const result = checkWatchCompletion(progressMs, totalMs, tvThreshold); + expect(result).toBe(true); + }); + }); + + // Marker-based completion (future enhancement) + describe.skip('marker-based completion', () => { + it('should mark as watched when reaching credits marker', () => { + // Plex provides intro/credits markers + // If credits marker exists and user passes it, mark as watched + // This requires API integration to get markers + }); + }); +}); + +// ============================================================================ +// Stale Stream Force-Stop +// ============================================================================ +// Stop sessions after 5 minutes of no updates +describe('Stale Stream Force-Stop', () => { + describe('shouldForceStopStaleSession', () => { + // This function doesn't exist yet - tests will fail + it('should return true when session has no updates for 5+ minutes', async () => { + const { shouldForceStopStaleSession } = await import('../stateTracker.js'); + + // 5 minutes + 1 second (strictly greater than threshold) + const moreThanFiveMinutesAgo = new Date(Date.now() - (5 * 60 * 1000 + 1000)); + const result = shouldForceStopStaleSession(moreThanFiveMinutesAgo); + + expect(result).toBe(true); + }); + + it('should return false when session was updated within 5 minutes', async () => { + const { shouldForceStopStaleSession } = await import('../stateTracker.js'); + + const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000); + const result = shouldForceStopStaleSession(threeMinutesAgo); + + expect(result).toBe(false); + }); + + it('should return false when lastSeenAt is exactly 5 minutes ago', async () => { + const { shouldForceStopStaleSession } = await import('../stateTracker.js'); + + // Edge case: exactly at threshold should NOT stop + const exactlyFiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const result = shouldForceStopStaleSession(exactlyFiveMinutesAgo); + + expect(result).toBe(false); + }); + + it('should accept configurable timeout in seconds', async () => { + const { shouldForceStopStaleSession } = await import('../stateTracker.js'); + + // 3 minute custom timeout + const threeMinuteTimeout = 180; + const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000 - 1); + + const result = shouldForceStopStaleSession(threeMinutesAgo, threeMinuteTimeout); + expect(result).toBe(true); + }); + }); + + describe('integration with session processing', () => { + it('should mark force-stopped sessions with forceStopped flag', async () => { + // The database schema should have a forceStopped boolean field + // When a session is force-stopped, this flag should be set to true + // This test verifies the schema has the field + const { sessions } = await import('../../../db/schema.js'); + + expect(sessions).toHaveProperty('forceStopped'); + }); + }); +}); + +// ============================================================================ +// Minimum Play Time Filtering +// ============================================================================ +// Use LOGGING_IGNORE_INTERVAL = 120 seconds +describe('Minimum Play Time Filtering', () => { + describe('shouldRecordSession', () => { + // This function doesn't exist yet - tests will fail + it('should NOT record session with < 120 seconds play time', async () => { + const { shouldRecordSession } = await import('../stateTracker.js'); + + const durationMs = 119 * 1000; // 119 seconds + const result = shouldRecordSession(durationMs); + + expect(result).toBe(false); + }); + + it('should record session with >= 120 seconds play time', async () => { + const { shouldRecordSession } = await import('../stateTracker.js'); + + const durationMs = 120 * 1000; // Exactly 120 seconds + const result = shouldRecordSession(durationMs); + + expect(result).toBe(true); + }); + + it('should accept custom minimum play time', async () => { + const { shouldRecordSession } = await import('../stateTracker.js'); + + // Some users want stricter filtering (e.g., 5 minutes) + const customMinMs = 5 * 60 * 1000; + const durationMs = 4 * 60 * 1000; // 4 minutes + + const result = shouldRecordSession(durationMs, customMinMs); + expect(result).toBe(false); + }); + + it('should always record sessions with 0 minimum configured', async () => { + const { shouldRecordSession } = await import('../stateTracker.js'); + + const durationMs = 1000; // 1 second + const result = shouldRecordSession(durationMs, 0); + + expect(result).toBe(true); + }); + }); + + describe('media type specific filtering', () => { + it('should apply different minimums for movies vs episodes', async () => { + const { shouldRecordSession } = await import('../stateTracker.js'); + + // Movies might have higher threshold + const movieMinMs = 180 * 1000; // 3 minutes for movies + const episodeMinMs = 120 * 1000; // 2 minutes for episodes + + // 2.5 minute watch + const durationMs = 150 * 1000; + + const recordMovie = shouldRecordSession(durationMs, movieMinMs); + const recordEpisode = shouldRecordSession(durationMs, episodeMinMs); + + expect(recordMovie).toBe(false); // Under 3 min threshold + expect(recordEpisode).toBe(true); // Over 2 min threshold + }); + }); +}); + +// ============================================================================ +// Continued Session Threshold +// ============================================================================ +// Use CONTINUED_SESSION_THRESHOLD = 60 seconds +describe('Continued Session Threshold', () => { + describe('shouldGroupWithPreviousSession with configurable threshold', () => { + const baseSession = { + id: 'previous-session-id', + referenceId: null, + progressMs: 30 * 60 * 1000, // 30 minutes + watched: false, + stoppedAt: new Date(), + }; + + it('should NOT group sessions with > 60 second gap when continued session is close', () => { + // If new session starts at same progress but previous stopped > 60s ago, + // it should NOT be grouped (it's a fresh start, not a continue) + + const sixtyOneSecondsAgo = new Date(Date.now() - 61 * 1000); + + // This should return null because it's been too long since previous session + const result = shouldGroupWithPreviousSession( + { ...baseSession, stoppedAt: sixtyOneSecondsAgo }, + 30 * 60 * 1000 // Same progress + ); + + // With 60s default threshold, should NOT group + expect(result).toBeNull(); + }); + + it('should group sessions within 60 second gap', () => { + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + const result = shouldGroupWithPreviousSession( + { ...baseSession, stoppedAt: thirtySecondsAgo }, + 30 * 60 * 1000 + ); + + expect(result).toBe('previous-session-id'); + }); + + it('should accept configurable continued session threshold', () => { + // Some users want longer window for continued sessions + const customThresholdMs = 5 * 60 * 1000; // 5 minutes + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); + + // Third parameter is optional threshold in ms + const result = shouldGroupWithPreviousSession( + { ...baseSession, stoppedAt: twoMinutesAgo }, + 30 * 60 * 1000, + customThresholdMs // Custom threshold + ); + + // Within 5 minute threshold, should group + expect(result).toBe('previous-session-id'); + }); + }); + + describe('24 hour maximum for session grouping', () => { + it('should NEVER group sessions more than 24 hours apart regardless of threshold', () => { + const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: 'old-session', + referenceId: null, + progressMs: 30 * 60 * 1000, + watched: false, + stoppedAt: twentyFiveHoursAgo, + }, + 30 * 60 * 1000 + ); + + expect(result).toBeNull(); + }); + }); +}); + +// ============================================================================ +// Integration Test: Full Session Lifecycle with Edge Cases +// ============================================================================ +describe('Integration: Full Session Lifecycle', () => { + it('should handle complete watch session with 85% completion', () => { + // 2 hour movie, watched 1h42m (85%) + const totalMs = 2 * 60 * 60 * 1000; // 2 hours + const progressMs = 1.7 * 60 * 60 * 1000; // 1h42m = 85% + + const watched = checkWatchCompletion(progressMs, totalMs); + expect(watched).toBe(true); + }); + + it('should handle partial watch below 85% threshold', () => { + // 2 hour movie, watched 1h36m (80%) + const totalMs = 2 * 60 * 60 * 1000; + const progressMs = 1.6 * 60 * 60 * 1000; // 1h36m = 80% + + const watched = checkWatchCompletion(progressMs, totalMs); + // With 85% threshold, 80% is NOT watched + expect(watched).toBe(false); + }); +}); diff --git a/apps/server/src/jobs/poller/__tests__/stateTracker.test.ts b/apps/server/src/jobs/poller/__tests__/stateTracker.test.ts new file mode 100644 index 0000000..9a264e5 --- /dev/null +++ b/apps/server/src/jobs/poller/__tests__/stateTracker.test.ts @@ -0,0 +1,454 @@ +/** + * State Tracker Tests + * + * Tests session state tracking functions from poller/stateTracker.ts: + * - calculatePauseAccumulation: Track pause duration across state transitions + * - calculateStopDuration: Calculate final watch time when session stops + * - checkWatchCompletion: Determine if content was "watched" (80% threshold) + * - shouldGroupWithPreviousSession: Link resumed sessions together + */ + +import { describe, it, expect } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { + calculatePauseAccumulation, + calculateStopDuration, + checkWatchCompletion, + isQualityChangeScenario, + shouldGroupWithPreviousSession, +} from '../stateTracker.js'; + +describe('calculatePauseAccumulation', () => { + describe('state transitions', () => { + it('should record lastPausedAt when transitioning from playing to paused', () => { + const now = new Date(); + const result = calculatePauseAccumulation( + 'playing', + 'paused', + { lastPausedAt: null, pausedDurationMs: 0 }, + now + ); + + expect(result.lastPausedAt).toEqual(now); + expect(result.pausedDurationMs).toBe(0); + }); + + it('should accumulate pause duration when transitioning from paused to playing', () => { + const pauseStart = new Date('2024-01-01T10:00:00Z'); + const resumeTime = new Date('2024-01-01T10:30:00Z'); // 30 minutes later + + const result = calculatePauseAccumulation( + 'paused', + 'playing', + { lastPausedAt: pauseStart, pausedDurationMs: 0 }, + resumeTime + ); + + expect(result.lastPausedAt).toBeNull(); + expect(result.pausedDurationMs).toBe(30 * 60 * 1000); + }); + + it('should not change anything for playing to playing transition', () => { + const now = new Date(); + const existingSession = { lastPausedAt: null, pausedDurationMs: 5000 }; + + const result = calculatePauseAccumulation('playing', 'playing', existingSession, now); + + expect(result.lastPausedAt).toBeNull(); + expect(result.pausedDurationMs).toBe(5000); + }); + + it('should not change anything for paused to paused transition', () => { + const pausedAt = new Date('2024-01-01T10:00:00Z'); + const now = new Date('2024-01-01T10:30:00Z'); + const existingSession = { lastPausedAt: pausedAt, pausedDurationMs: 5000 }; + + const result = calculatePauseAccumulation('paused', 'paused', existingSession, now); + + expect(result.lastPausedAt).toEqual(pausedAt); + expect(result.pausedDurationMs).toBe(5000); + }); + }); + + describe('multiple pause cycles', () => { + it('should accumulate correctly across multiple pause/resume cycles', () => { + const times = { + pause1: new Date('2024-01-01T10:05:00Z'), + resume1: new Date('2024-01-01T10:10:00Z'), // 5 min pause + pause2: new Date('2024-01-01T10:15:00Z'), + resume2: new Date('2024-01-01T10:25:00Z'), // 10 min pause + }; + + let session = { lastPausedAt: null as Date | null, pausedDurationMs: 0 }; + + // First pause + session = calculatePauseAccumulation('playing', 'paused', session, times.pause1); + expect(session.lastPausedAt).toEqual(times.pause1); + + // First resume - 5 min accumulated + session = calculatePauseAccumulation('paused', 'playing', session, times.resume1); + expect(session.pausedDurationMs).toBe(5 * 60 * 1000); + + // Second pause + session = calculatePauseAccumulation('playing', 'paused', session, times.pause2); + expect(session.lastPausedAt).toEqual(times.pause2); + + // Second resume - 15 min total (5 + 10) + session = calculatePauseAccumulation('paused', 'playing', session, times.resume2); + expect(session.pausedDurationMs).toBe(15 * 60 * 1000); + expect(session.lastPausedAt).toBeNull(); + }); + }); +}); + +describe('calculateStopDuration', () => { + describe('basic duration calculation', () => { + it('should calculate correct duration for session with no pauses', () => { + const startedAt = new Date('2024-01-01T10:00:00Z'); + const stoppedAt = new Date('2024-01-01T12:00:00Z'); // 2 hours later + + const result = calculateStopDuration( + { startedAt, lastPausedAt: null, pausedDurationMs: 0 }, + stoppedAt + ); + + expect(result.durationMs).toBe(2 * 60 * 60 * 1000); + expect(result.finalPausedDurationMs).toBe(0); + }); + + it('should exclude accumulated pause time from duration', () => { + const startedAt = new Date('2024-01-01T10:00:00Z'); + const stoppedAt = new Date('2024-01-01T12:00:00Z'); + + const result = calculateStopDuration( + { + startedAt, + lastPausedAt: null, + pausedDurationMs: 30 * 60 * 1000, // 30 minutes paused + }, + stoppedAt + ); + + expect(result.durationMs).toBe(1.5 * 60 * 60 * 1000); + expect(result.finalPausedDurationMs).toBe(30 * 60 * 1000); + }); + }); + + describe('stopped while paused', () => { + it('should include remaining pause time if stopped while paused', () => { + const startedAt = new Date('2024-01-01T10:00:00Z'); + const pausedAt = new Date('2024-01-01T11:30:00Z'); + const stoppedAt = new Date('2024-01-01T12:00:00Z'); + + const result = calculateStopDuration( + { + startedAt, + lastPausedAt: pausedAt, + pausedDurationMs: 15 * 60 * 1000, // 15 minutes already accumulated + }, + stoppedAt + ); + + // Total elapsed: 2 hours + // Paused: 15 min (previous) + 30 min (current) = 45 min + // Watch time: 2 hours - 45 min = 1.25 hours + expect(result.finalPausedDurationMs).toBe(45 * 60 * 1000); + expect(result.durationMs).toBe(1.25 * 60 * 60 * 1000); + }); + }); + + describe('edge cases', () => { + it('should not return negative duration', () => { + const startedAt = new Date('2024-01-01T10:00:00Z'); + const stoppedAt = new Date('2024-01-01T10:30:00Z'); + + const result = calculateStopDuration( + { + startedAt, + lastPausedAt: null, + pausedDurationMs: 60 * 60 * 1000, // More than elapsed + }, + stoppedAt + ); + + expect(result.durationMs).toBe(0); + }); + }); + + describe('real-world scenarios', () => { + it('should handle movie with dinner break', () => { + const startedAt = new Date('2024-01-01T18:00:00Z'); + const stoppedAt = new Date('2024-01-01T21:00:00Z'); // 3 hours wall clock + + const result = calculateStopDuration( + { + startedAt, + lastPausedAt: null, + pausedDurationMs: 60 * 60 * 1000, // 1 hour dinner pause + }, + stoppedAt + ); + + expect(result.durationMs).toBe(2 * 60 * 60 * 1000); + }); + }); +}); + +describe('checkWatchCompletion', () => { + describe('85% threshold (industry standard)', () => { + it('should return true when progress >= 85%', () => { + expect(checkWatchCompletion(8500, 10000)).toBe(true); // Exactly 85% + expect(checkWatchCompletion(9000, 10000)).toBe(true); // 90% + expect(checkWatchCompletion(10000, 10000)).toBe(true); // 100% + }); + + it('should return false when progress < 85%', () => { + expect(checkWatchCompletion(8499, 10000)).toBe(false); // Just under 85% + expect(checkWatchCompletion(8000, 10000)).toBe(false); // 80% + expect(checkWatchCompletion(5000, 10000)).toBe(false); // 50% + }); + }); + + describe('null handling', () => { + it('should return false when progressMs is null', () => { + expect(checkWatchCompletion(null, 10000)).toBe(false); + }); + + it('should return false when totalDurationMs is null', () => { + expect(checkWatchCompletion(8000, null)).toBe(false); + }); + + it('should return false when both are null', () => { + expect(checkWatchCompletion(null, null)).toBe(false); + }); + }); +}); + +describe('shouldGroupWithPreviousSession', () => { + describe('session grouping', () => { + it('should group when resuming from same progress within threshold', () => { + const previousSessionId = randomUUID(); + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: previousSessionId, + referenceId: null, + progressMs: 30 * 60 * 1000, + watched: false, + stoppedAt: thirtySecondsAgo, + }, + 30 * 60 * 1000 + ); + + expect(result).toBe(previousSessionId); + }); + + it('should use existing referenceId for chained sessions', () => { + const originalSessionId = randomUUID(); + const previousSessionId = randomUUID(); + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: previousSessionId, + referenceId: originalSessionId, + progressMs: 60 * 60 * 1000, + watched: false, + stoppedAt: thirtySecondsAgo, + }, + 60 * 60 * 1000 + ); + + expect(result).toBe(originalSessionId); + }); + }); + + describe('no grouping conditions', () => { + it('should not group if previous session was fully watched', () => { + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: randomUUID(), + referenceId: null, + progressMs: 90 * 60 * 1000, + watched: true, + stoppedAt: thirtySecondsAgo, + }, + 0 + ); + + expect(result).toBeNull(); + }); + + it('should not group if previous session is older than 24 hours', () => { + const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: randomUUID(), + referenceId: null, + progressMs: 30 * 60 * 1000, + watched: false, + stoppedAt: twoDaysAgo, + }, + 30 * 60 * 1000 + ); + + expect(result).toBeNull(); + }); + + it('should not group if user rewound (new progress < previous)', () => { + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: randomUUID(), + referenceId: null, + progressMs: 60 * 60 * 1000, + watched: false, + stoppedAt: thirtySecondsAgo, + }, + 30 * 60 * 1000 // Rewound + ); + + expect(result).toBeNull(); + }); + + it('should not group if gap exceeds default threshold (60s)', () => { + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); + + const result = shouldGroupWithPreviousSession( + { + id: randomUUID(), + referenceId: null, + progressMs: 30 * 60 * 1000, + watched: false, + stoppedAt: twoMinutesAgo, + }, + 30 * 60 * 1000 + ); + + expect(result).toBeNull(); + }); + }); +}); + +describe('Integration: Complete Watch Session', () => { + it('should handle complete watch session with multiple pauses', () => { + const times = { + start: new Date('2024-01-01T10:00:00Z'), + pause1: new Date('2024-01-01T10:30:00Z'), + resume1: new Date('2024-01-01T10:45:00Z'), // 15 min pause + pause2: new Date('2024-01-01T11:30:00Z'), + resume2: new Date('2024-01-01T12:00:00Z'), // 30 min pause + stop: new Date('2024-01-01T12:45:00Z'), + }; + + let session = { lastPausedAt: null as Date | null, pausedDurationMs: 0 }; + + session = calculatePauseAccumulation('playing', 'paused', session, times.pause1); + session = calculatePauseAccumulation('paused', 'playing', session, times.resume1); + session = calculatePauseAccumulation('playing', 'paused', session, times.pause2); + session = calculatePauseAccumulation('paused', 'playing', session, times.resume2); + + expect(session.pausedDurationMs).toBe(45 * 60 * 1000); + + const result = calculateStopDuration( + { startedAt: times.start, ...session }, + times.stop + ); + + // Wall clock: 2h 45m, Paused: 45m, Watch time: 2h + expect(result.durationMs).toBe(120 * 60 * 1000); + }); + + it('should correctly chain session groups', () => { + const session1Id = randomUUID(); + const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); + + // First resume - links to session1 + const ref1 = shouldGroupWithPreviousSession( + { + id: session1Id, + referenceId: null, + progressMs: 30 * 60 * 1000, + watched: false, + stoppedAt: thirtySecondsAgo, + }, + 30 * 60 * 1000 + ); + expect(ref1).toBe(session1Id); + + // Second resume - should still link to original + const session2Id = randomUUID(); + const ref2 = shouldGroupWithPreviousSession( + { + id: session2Id, + referenceId: session1Id, + progressMs: 60 * 60 * 1000, + watched: false, + stoppedAt: thirtySecondsAgo, + }, + 60 * 60 * 1000 + ); + expect(ref2).toBe(session1Id); + }); +}); + +describe('isQualityChangeScenario', () => { + describe('quality change detection', () => { + it('should return session id when active session exists for same user+content', () => { + const sessionId = randomUUID(); + + const result = isQualityChangeScenario({ + id: sessionId, + referenceId: null, + stoppedAt: null, // Active session + }); + + expect(result).toBe(sessionId); + }); + + it('should return original referenceId when session is already part of a chain', () => { + const originalSessionId = randomUUID(); + const currentSessionId = randomUUID(); + + const result = isQualityChangeScenario({ + id: currentSessionId, + referenceId: originalSessionId, // Already linked to original + stoppedAt: null, + }); + + expect(result).toBe(originalSessionId); + }); + }); + + describe('non-quality-change scenarios', () => { + it('should return null when no existing session', () => { + expect(isQualityChangeScenario(null)).toBeNull(); + expect(isQualityChangeScenario(undefined)).toBeNull(); + }); + + it('should return null when session is already stopped (resume scenario)', () => { + const result = isQualityChangeScenario({ + id: randomUUID(), + referenceId: null, + stoppedAt: new Date(), // Session stopped - not a quality change + }); + + expect(result).toBeNull(); + }); + + it('should return null for stopped session even with referenceId', () => { + const result = isQualityChangeScenario({ + id: randomUUID(), + referenceId: randomUUID(), + stoppedAt: new Date(), // Session stopped + }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/server/src/jobs/poller/__tests__/utils.test.ts b/apps/server/src/jobs/poller/__tests__/utils.test.ts new file mode 100644 index 0000000..6483e23 --- /dev/null +++ b/apps/server/src/jobs/poller/__tests__/utils.test.ts @@ -0,0 +1,308 @@ +/** + * Poller Utility Functions Tests + * + * Tests pure utility functions from poller/utils.ts: + * - formatQualityString: Format bitrate for display + * - isPrivateIP: Detect private/local IP addresses + * - parseJellyfinClient: Extract client info from Jellyfin user agent + */ + +import { describe, it, expect } from 'vitest'; +import { formatQualityString, isPrivateIP, parseJellyfinClient } from '../utils.js'; + +describe('formatQualityString', () => { + describe('bitrate formatting', () => { + it('should format transcode bitrate in Mbps', () => { + expect(formatQualityString(8000000, 0, false)).toBe('8Mbps'); + expect(formatQualityString(10000000, 0, true)).toBe('10Mbps'); + }); + + it('should fall back to source bitrate when transcode bitrate is 0', () => { + expect(formatQualityString(0, 12000000, false)).toBe('12Mbps'); + }); + + it('should round bitrate correctly', () => { + expect(formatQualityString(8500000, 0, false)).toBe('9Mbps'); // Rounds up + expect(formatQualityString(8400000, 0, false)).toBe('8Mbps'); // Rounds down + }); + }); + + describe('fallback labels', () => { + it('should return "Transcoding" when no bitrate but is transcoding', () => { + expect(formatQualityString(0, 0, true)).toBe('Transcoding'); + }); + + it('should return "Direct" when no bitrate and not transcoding', () => { + expect(formatQualityString(0, 0, false)).toBe('Direct'); + }); + }); +}); + +describe('isPrivateIP', () => { + describe('IPv4 private ranges', () => { + it('should detect 10.x.x.x as private (10.0.0.0/8)', () => { + expect(isPrivateIP('10.0.0.1')).toBe(true); + expect(isPrivateIP('10.255.255.255')).toBe(true); + expect(isPrivateIP('10.123.45.67')).toBe(true); + }); + + it('should detect 172.16-31.x.x as private (172.16.0.0/12)', () => { + expect(isPrivateIP('172.16.0.1')).toBe(true); + expect(isPrivateIP('172.31.255.255')).toBe(true); + expect(isPrivateIP('172.20.10.5')).toBe(true); + }); + + it('should NOT detect 172.15.x.x or 172.32.x.x as private', () => { + expect(isPrivateIP('172.15.0.1')).toBe(false); + expect(isPrivateIP('172.32.0.1')).toBe(false); + }); + + it('should detect 192.168.x.x as private (192.168.0.0/16)', () => { + expect(isPrivateIP('192.168.0.1')).toBe(true); + expect(isPrivateIP('192.168.1.1')).toBe(true); + expect(isPrivateIP('192.168.255.255')).toBe(true); + }); + + it('should detect 127.x.x.x as private (loopback)', () => { + expect(isPrivateIP('127.0.0.1')).toBe(true); + expect(isPrivateIP('127.255.255.255')).toBe(true); + }); + + it('should detect 169.254.x.x as private (link-local)', () => { + expect(isPrivateIP('169.254.0.1')).toBe(true); + expect(isPrivateIP('169.254.255.255')).toBe(true); + }); + + it('should detect 0.x.x.x as private (current network)', () => { + expect(isPrivateIP('0.0.0.0')).toBe(true); + expect(isPrivateIP('0.1.2.3')).toBe(true); + }); + }); + + describe('IPv4 public addresses', () => { + it('should NOT detect public IPs as private', () => { + expect(isPrivateIP('8.8.8.8')).toBe(false); // Google DNS + expect(isPrivateIP('1.1.1.1')).toBe(false); // Cloudflare DNS + expect(isPrivateIP('142.250.80.46')).toBe(false); // Google + expect(isPrivateIP('151.101.1.140')).toBe(false); // Reddit + expect(isPrivateIP('203.0.113.50')).toBe(false); // Documentation range but public + }); + }); + + describe('IPv6 private ranges', () => { + it('should detect ::1 as private (loopback)', () => { + expect(isPrivateIP('::1')).toBe(true); + }); + + it('should detect fe80: as private (link-local)', () => { + expect(isPrivateIP('fe80::1')).toBe(true); + expect(isPrivateIP('fe80:0:0:0:0:0:0:1')).toBe(true); + expect(isPrivateIP('FE80::abcd:1234')).toBe(true); // Case insensitive + }); + + it('should detect fc/fd as private (unique local)', () => { + expect(isPrivateIP('fc00::1')).toBe(true); + expect(isPrivateIP('fd00::1')).toBe(true); + expect(isPrivateIP('fdab:cdef:1234::1')).toBe(true); + }); + }); + + describe('IPv6 public addresses', () => { + it('should NOT detect public IPv6 as private', () => { + expect(isPrivateIP('2001:4860:4860::8888')).toBe(false); // Google DNS + expect(isPrivateIP('2606:4700:4700::1111')).toBe(false); // Cloudflare DNS + }); + }); + + describe('edge cases', () => { + it('should treat empty string as private', () => { + expect(isPrivateIP('')).toBe(true); + }); + + it('should treat null-like values as private', () => { + expect(isPrivateIP(null as unknown as string)).toBe(true); + expect(isPrivateIP(undefined as unknown as string)).toBe(true); + }); + }); +}); + +describe('parseJellyfinClient', () => { + describe('iOS devices', () => { + it('should parse "Jellyfin iOS" as iOS/iPhone', () => { + const result = parseJellyfinClient('Jellyfin iOS'); + expect(result.platform).toBe('iOS'); + expect(result.device).toBe('iPhone'); + }); + + it('should parse clients containing "iphone" as iOS/iPhone', () => { + const result = parseJellyfinClient('Jellyfin for iPhone'); + expect(result.platform).toBe('iOS'); + expect(result.device).toBe('iPhone'); + }); + + it('should parse "Jellyfin iPad" as iOS/iPad', () => { + const result = parseJellyfinClient('Jellyfin iPad'); + expect(result.platform).toBe('iOS'); + expect(result.device).toBe('iPad'); + }); + + it('should be case insensitive for iOS detection', () => { + expect(parseJellyfinClient('jellyfin IOS').platform).toBe('iOS'); + expect(parseJellyfinClient('JELLYFIN iOS').platform).toBe('iOS'); + }); + }); + + describe('Android devices', () => { + it('should parse "Jellyfin Android" as Android/Android', () => { + const result = parseJellyfinClient('Jellyfin Android'); + expect(result.platform).toBe('Android'); + expect(result.device).toBe('Android'); + }); + + it('should parse Android TV clients as Android TV', () => { + const result = parseJellyfinClient('Jellyfin Android TV'); + expect(result.platform).toBe('Android TV'); + expect(result.device).toBe('Android TV'); + }); + + it('should parse Shield clients as Android TV', () => { + const result = parseJellyfinClient('Jellyfin for Shield'); + expect(result.platform).toBe('Android TV'); + expect(result.device).toBe('Android TV'); + }); + + it('should parse NVIDIA Shield with Android in name', () => { + const result = parseJellyfinClient('Jellyfin Android Shield'); + expect(result.platform).toBe('Android TV'); + expect(result.device).toBe('Android TV'); + }); + + it('should parse just "Shield" as Android TV', () => { + const result = parseJellyfinClient('Shield'); + expect(result.platform).toBe('Android TV'); + expect(result.device).toBe('Android TV'); + }); + }); + + describe('Smart TVs', () => { + it('should parse Samsung/Tizen clients as Samsung TV', () => { + expect(parseJellyfinClient('Jellyfin Samsung')).toEqual({ + platform: 'Tizen', + device: 'Samsung TV', + }); + expect(parseJellyfinClient('Jellyfin Tizen')).toEqual({ + platform: 'Tizen', + device: 'Samsung TV', + }); + }); + + it('should parse LG/webOS clients as LG TV', () => { + expect(parseJellyfinClient('Jellyfin webOS')).toEqual({ + platform: 'webOS', + device: 'LG TV', + }); + expect(parseJellyfinClient('Jellyfin LG')).toEqual({ + platform: 'webOS', + device: 'LG TV', + }); + }); + + it('should parse Roku clients as Roku', () => { + expect(parseJellyfinClient('Jellyfin Roku')).toEqual({ + platform: 'Roku', + device: 'Roku', + }); + }); + }); + + describe('Apple TV', () => { + it('should parse tvOS clients as Apple TV', () => { + expect(parseJellyfinClient('Jellyfin tvOS')).toEqual({ + platform: 'tvOS', + device: 'Apple TV', + }); + }); + + it('should parse "Apple TV" in client name as Apple TV', () => { + expect(parseJellyfinClient('Jellyfin Apple TV')).toEqual({ + platform: 'tvOS', + device: 'Apple TV', + }); + }); + + it('should parse Swiftfin as Apple TV', () => { + expect(parseJellyfinClient('Swiftfin')).toEqual({ + platform: 'tvOS', + device: 'Apple TV', + }); + }); + }); + + describe('Web browsers', () => { + it('should parse "Jellyfin Web" as Web/Browser', () => { + expect(parseJellyfinClient('Jellyfin Web')).toEqual({ + platform: 'Web', + device: 'Browser', + }); + }); + }); + + describe('Media players', () => { + it('should parse Kodi clients as Kodi', () => { + expect(parseJellyfinClient('Kodi')).toEqual({ + platform: 'Kodi', + device: 'Kodi', + }); + expect(parseJellyfinClient('Jellyfin for Kodi')).toEqual({ + platform: 'Kodi', + device: 'Kodi', + }); + }); + + it('should parse Infuse clients as Infuse', () => { + expect(parseJellyfinClient('Infuse')).toEqual({ + platform: 'Infuse', + device: 'Infuse', + }); + }); + }); + + describe('deviceType parameter', () => { + it('should use deviceType when provided and meaningful', () => { + const result = parseJellyfinClient('Custom Client', 'Smart TV'); + expect(result.platform).toBe('Custom Client'); + expect(result.device).toBe('Smart TV'); + }); + + it('should ignore deviceType when empty', () => { + const result = parseJellyfinClient('Jellyfin iOS', ''); + expect(result.platform).toBe('iOS'); + expect(result.device).toBe('iPhone'); + }); + + it('should ignore deviceType when "Unknown"', () => { + const result = parseJellyfinClient('Jellyfin Android', 'Unknown'); + expect(result.platform).toBe('Android'); + expect(result.device).toBe('Android'); + }); + + it('should prefer deviceType over parsing when deviceType is meaningful', () => { + const result = parseJellyfinClient('Jellyfin iOS', 'Custom Device'); + expect(result.device).toBe('Custom Device'); + }); + }); + + describe('fallback behavior', () => { + it('should use client name as fallback for unknown clients', () => { + const result = parseJellyfinClient('Unknown Client App'); + expect(result.platform).toBe('Unknown Client App'); + expect(result.device).toBe('Unknown Client App'); + }); + + it('should handle empty client string', () => { + const result = parseJellyfinClient(''); + expect(result.platform).toBe('Unknown'); + expect(result.device).toBe('Unknown'); + }); + }); +}); diff --git a/apps/server/src/jobs/poller/__tests__/violations.test.ts b/apps/server/src/jobs/poller/__tests__/violations.test.ts new file mode 100644 index 0000000..192b9f3 --- /dev/null +++ b/apps/server/src/jobs/poller/__tests__/violations.test.ts @@ -0,0 +1,48 @@ +/** + * Violations Module Tests + * + * Tests rule/violation functions from poller/violations.ts: + * - getTrustScorePenalty: Map violation severity to trust score penalty + * - doesRuleApplyToUser: Check if a rule applies to a specific user + */ + +import { describe, it, expect } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { getTrustScorePenalty, doesRuleApplyToUser } from '../violations.js'; + +describe('getTrustScorePenalty', () => { + describe('severity mapping', () => { + it('should return 20 for HIGH severity', () => { + expect(getTrustScorePenalty('high')).toBe(20); + }); + + it('should return 10 for WARNING severity', () => { + expect(getTrustScorePenalty('warning')).toBe(10); + }); + + it('should return 5 for LOW severity', () => { + expect(getTrustScorePenalty('low')).toBe(5); + }); + }); +}); + +describe('doesRuleApplyToUser', () => { + describe('global rules', () => { + it('should apply global rules (serverUserId=null) to any user', () => { + const globalRule = { serverUserId: null }; + expect(doesRuleApplyToUser(globalRule, randomUUID())).toBe(true); + expect(doesRuleApplyToUser(globalRule, randomUUID())).toBe(true); + }); + }); + + describe('user-specific rules', () => { + it('should apply user-specific rule only to that user', () => { + const targetServerUserId = randomUUID(); + const otherServerUserId = randomUUID(); + const userRule = { serverUserId: targetServerUserId }; + + expect(doesRuleApplyToUser(userRule, targetServerUserId)).toBe(true); + expect(doesRuleApplyToUser(userRule, otherServerUserId)).toBe(false); + }); + }); +}); diff --git a/apps/server/src/jobs/poller/database.ts b/apps/server/src/jobs/poller/database.ts new file mode 100644 index 0000000..da9e313 --- /dev/null +++ b/apps/server/src/jobs/poller/database.ts @@ -0,0 +1,94 @@ +/** + * Poller Database Operations + * + * Database query functions used by the poller. + * Includes batch loading for performance optimization and rule fetching. + */ + +import { eq, and, desc, gte, inArray } from 'drizzle-orm'; +import { TIME_MS, SESSION_LIMITS, type Session, type Rule, type RuleParams } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { sessions, rules } from '../../db/schema.js'; +import { mapSessionRow } from './sessionMapper.js'; + +// ============================================================================ +// Session Batch Loading +// ============================================================================ + +/** + * Batch load recent sessions for multiple server users (eliminates N+1 in polling loop) + * + * This function fetches sessions from the last N hours for a batch of server users + * in a single query, avoiding the performance penalty of querying per-user. + * + * @param serverUserIds - Array of server user IDs to load sessions for + * @param hours - Number of hours to look back (default: 24) + * @returns Map of serverUserId -> Session[] for each server user + * + * @example + * const sessionMap = await batchGetRecentUserSessions(['su-1', 'su-2', 'su-3']); + * const user1Sessions = sessionMap.get('su-1') ?? []; + */ +export async function batchGetRecentUserSessions( + serverUserIds: string[], + hours = 24 +): Promise> { + if (serverUserIds.length === 0) return new Map(); + + const since = new Date(Date.now() - hours * TIME_MS.HOUR); + const result = new Map(); + + // Initialize empty arrays for all server users + for (const serverUserId of serverUserIds) { + result.set(serverUserId, []); + } + + // Single query to get recent sessions for all server users using inArray + const recentSessions = await db + .select() + .from(sessions) + .where(and( + inArray(sessions.serverUserId, serverUserIds), + gte(sessions.startedAt, since) + )) + .orderBy(desc(sessions.startedAt)); + + // Group by server user (limit per user to prevent memory issues) + for (const s of recentSessions) { + const userSessions = result.get(s.serverUserId) ?? []; + if (userSessions.length < SESSION_LIMITS.MAX_RECENT_PER_USER) { + userSessions.push(mapSessionRow(s)); + } + result.set(s.serverUserId, userSessions); + } + + return result; +} + +// ============================================================================ +// Rule Loading +// ============================================================================ + +/** + * Get all active rules for evaluation + * + * @returns Array of active Rule objects + * + * @example + * const rules = await getActiveRules(); + * // Evaluate each session against these rules + */ +export async function getActiveRules(): Promise { + const activeRules = await db.select().from(rules).where(eq(rules.isActive, true)); + + return activeRules.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + params: r.params as unknown as RuleParams, + serverUserId: r.serverUserId, + isActive: r.isActive, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); +} diff --git a/apps/server/src/jobs/poller/index.ts b/apps/server/src/jobs/poller/index.ts new file mode 100644 index 0000000..4337bd3 --- /dev/null +++ b/apps/server/src/jobs/poller/index.ts @@ -0,0 +1,78 @@ +/** + * Poller Module + * + * Background job for polling Plex/Jellyfin servers for active sessions. + * This module provides a unified interface for session tracking, including: + * - Automatic polling on configurable intervals + * - Session state tracking (playing, paused, stopped) + * - Pause duration accumulation + * - Watch completion detection (85% threshold) + * - Session grouping for resume tracking + * - Rule evaluation and violation creation + * - Stale session detection and force-stop (5 minute timeout, 60s sweep) + * - Minimum play time filtering (120s threshold) + * + * @example + * import { initializePoller, startPoller, stopPoller, sweepStaleSessions } from './jobs/poller'; + * + * // Initialize with cache services and Redis client + * initializePoller(cacheService, pubSubService, redis); + * + * // Start polling (also starts stale session sweep on 60s interval) + * startPoller({ enabled: true, intervalMs: 15000 }); + * + * // Manually trigger stale session sweep + * await sweepStaleSessions(); + * + * // Stop polling (also stops stale session sweep) + * stopPoller(); + */ + +// ============================================================================ +// Public API - Lifecycle Management +// ============================================================================ + +export { + initializePoller, + startPoller, + stopPoller, + triggerPoll, + triggerReconciliationPoll, + sweepStaleSessions, +} from './processor.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export type { PollerConfig } from './types.js'; + +// ============================================================================ +// Pure Utility Functions (exported for testing) +// ============================================================================ + +export { + isPrivateIP, + parseJellyfinClient, + formatQualityString, +} from './utils.js'; + +// ============================================================================ +// State Tracking Functions (exported for testing) +// ============================================================================ + +export { + calculatePauseAccumulation, + calculateStopDuration, + checkWatchCompletion, + shouldGroupWithPreviousSession, +} from './stateTracker.js'; + +// ============================================================================ +// Rule/Violation Functions (exported for testing) +// ============================================================================ + +export { + getTrustScorePenalty, + doesRuleApplyToUser, +} from './violations.js'; diff --git a/apps/server/src/jobs/poller/processor.ts b/apps/server/src/jobs/poller/processor.ts new file mode 100644 index 0000000..b95cd8c --- /dev/null +++ b/apps/server/src/jobs/poller/processor.ts @@ -0,0 +1,1289 @@ +/** + * Session Processor + * + * Core processing logic for the poller: + * - processServerSessions: Process sessions from a single server + * - pollServers: Orchestrate polling across all servers + * - Lifecycle management: start, stop, trigger + */ + +import { eq, and, desc, isNull, gte, lte, inArray } from 'drizzle-orm'; +import { POLLING_INTERVALS, TIME_MS, REDIS_KEYS, SESSION_LIMITS, type ActiveSession, type SessionState, type Rule } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { servers, serverUsers, sessions, users } from '../../db/schema.js'; +import { createMediaServerClient } from '../../services/mediaServer/index.js'; +import { geoipService, type GeoLocation } from '../../services/geoip.js'; +import { ruleEngine } from '../../services/rules.js'; +import type { CacheService, PubSubService } from '../../services/cache.js'; +import type { Redis } from 'ioredis'; +import { sseManager } from '../../services/sseManager.js'; + +import type { PollerConfig, ServerWithToken, ServerProcessingResult } from './types.js'; +import { mapMediaSession } from './sessionMapper.js'; +import { batchGetRecentUserSessions, getActiveRules } from './database.js'; +import { + calculatePauseAccumulation, + calculateStopDuration, + checkWatchCompletion, + shouldForceStopStaleSession, + shouldRecordSession, +} from './stateTracker.js'; +import { createViolationInTransaction, broadcastViolations, doesRuleApplyToUser, isDuplicateViolation, type ViolationInsertResult } from './violations.js'; +import { enqueueNotification } from '../notificationQueue.js'; + +// ============================================================================ +// Module State +// ============================================================================ + +let pollingInterval: NodeJS.Timeout | null = null; +let staleSweepInterval: NodeJS.Timeout | null = null; +let cacheService: CacheService | null = null; +let pubSubService: PubSubService | null = null; +let redisClient: Redis | null = null; + +const defaultConfig: PollerConfig = { + enabled: true, + intervalMs: POLLING_INTERVALS.SESSIONS, +}; + +// ============================================================================ +// Server Session Processing +// ============================================================================ + +/** + * Process a single server's sessions + * + * This function: + * 1. Fetches current sessions from the media server + * 2. Creates/updates users as needed + * 3. Creates new session records for new playbacks + * 4. Updates existing sessions with state changes + * 5. Marks stopped sessions as stopped + * 6. Evaluates rules and creates violations + * + * @param server - Server to poll + * @param activeRules - Active rules for evaluation + * @param cachedSessionKeys - Set of currently cached session keys + * @returns Processing results (new, updated, stopped sessions) + */ +async function processServerSessions( + server: ServerWithToken, + activeRules: Rule[], + cachedSessionKeys: Set +): Promise { + const newSessions: ActiveSession[] = []; + const updatedSessions: ActiveSession[] = []; + const currentSessionKeys = new Set(); + + try { + // Fetch sessions from server using unified adapter + const client = createMediaServerClient({ + type: server.type, + url: server.url, + token: server.token, + }); + const mediaSessions = await client.getSessions(); + const processedSessions = mediaSessions.map((s) => mapMediaSession(s, server.type)); + + // OPTIMIZATION: Early return if no active sessions from media server + if (processedSessions.length === 0) { + // Still need to handle stopped sessions detection + const stoppedSessionKeys: string[] = []; + for (const cachedKey of cachedSessionKeys) { + if (cachedKey.startsWith(`${server.id}:`)) { + stoppedSessionKeys.push(cachedKey); + + // Mark session as stopped in database + const sessionKey = cachedKey.replace(`${server.id}:`, ''); + const stoppedRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, server.id), + eq(sessions.sessionKey, sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + const stoppedSession = stoppedRows[0]; + if (stoppedSession) { + const stoppedAt = new Date(); + const { durationMs, finalPausedDurationMs } = calculateStopDuration( + { + startedAt: stoppedSession.startedAt, + lastPausedAt: stoppedSession.lastPausedAt, + pausedDurationMs: stoppedSession.pausedDurationMs || 0, + }, + stoppedAt + ); + const watched = stoppedSession.watched || checkWatchCompletion( + stoppedSession.progressMs, + stoppedSession.totalDurationMs + ); + + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt, + durationMs, + pausedDurationMs: finalPausedDurationMs, + lastPausedAt: null, + watched, + }) + .where(eq(sessions.id, stoppedSession.id)); + } + } + } + + return { success: true, newSessions: [], stoppedSessionKeys, updatedSessions: [] }; + } + + // OPTIMIZATION: Only load server users that match active sessions (not all users for server) + // Collect unique externalIds from current sessions + const sessionExternalIds = [...new Set(processedSessions.map((s) => s.externalUserId))]; + + const serverUsersList = await db + .select({ + id: serverUsers.id, + userId: serverUsers.userId, + serverId: serverUsers.serverId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + identityName: users.name, + }) + .from(serverUsers) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .where( + and( + eq(serverUsers.serverId, server.id), + inArray(serverUsers.externalId, sessionExternalIds) + ) + ); + + // Build server user caches: externalId -> serverUser and id -> serverUser + const serverUserByExternalId = new Map(); + const serverUserById = new Map(); + for (const serverUser of serverUsersList) { + if (serverUser.externalId) { + serverUserByExternalId.set(serverUser.externalId, serverUser); + } + serverUserById.set(serverUser.id, serverUser); + } + + // Track server users that need to be created and their session indices + const serverUsersToCreate: { externalId: string; username: string; thumbUrl: string | null; sessionIndex: number }[] = []; + + // First pass: identify server users and resolve from cache or mark for creation + const sessionServerUserIds: (string | null)[] = []; + + for (let i = 0; i < processedSessions.length; i++) { + const processed = processedSessions[i]!; + const existingServerUser = serverUserByExternalId.get(processed.externalUserId); + + if (existingServerUser) { + // Check if server user data needs update + const needsUpdate = + existingServerUser.username !== processed.username || + (processed.userThumb && existingServerUser.thumbUrl !== processed.userThumb); + + if (needsUpdate) { + await db + .update(serverUsers) + .set({ + username: processed.username, + thumbUrl: processed.userThumb || existingServerUser.thumbUrl, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, existingServerUser.id)); + + // Update cache + existingServerUser.username = processed.username; + if (processed.userThumb) existingServerUser.thumbUrl = processed.userThumb; + } + + sessionServerUserIds.push(existingServerUser.id); + } else { + // Need to create server user - mark for batch creation + serverUsersToCreate.push({ + externalId: processed.externalUserId, + username: processed.username, + thumbUrl: processed.userThumb || null, + sessionIndex: i, + }); + sessionServerUserIds.push(null); // Will be filled after creation + } + } + + // Batch create new server users (and their identity users) + if (serverUsersToCreate.length > 0) { + // First, create identity users for each new server user + const newIdentityUsers = await db + .insert(users) + .values(serverUsersToCreate.map(u => ({ + username: u.username, // Login identifier + name: u.username, // Use username as initial display name + thumbnail: u.thumbUrl, + }))) + .returning(); + + // Then create server users linked to the identity users + const newServerUsers = await db + .insert(serverUsers) + .values(serverUsersToCreate.map((u, idx) => ({ + userId: newIdentityUsers[idx]!.id, + serverId: server.id, + externalId: u.externalId, + username: u.username, + thumbUrl: u.thumbUrl, + }))) + .returning(); + + // Update sessionServerUserIds with newly created server user IDs + for (let i = 0; i < serverUsersToCreate.length; i++) { + const serverUserToCreate = serverUsersToCreate[i]!; + const newServerUser = newServerUsers[i]; + const newIdentityUser = newIdentityUsers[i]; + if (newServerUser && newIdentityUser) { + sessionServerUserIds[serverUserToCreate.sessionIndex] = newServerUser.id; + // Add to cache with identityName from the identity user + const serverUserWithIdentity = { + ...newServerUser, + identityName: newIdentityUser.name, + }; + serverUserById.set(newServerUser.id, serverUserWithIdentity); + serverUserByExternalId.set(serverUserToCreate.externalId, serverUserWithIdentity); + } + } + } + + // OPTIMIZATION: Batch load recent sessions for rule evaluation + // Only load for server users with new sessions (not cached) + const serverUsersWithNewSessions = new Set(); + for (let i = 0; i < processedSessions.length; i++) { + const processed = processedSessions[i]!; + const sessionKey = `${server.id}:${processed.sessionKey}`; + const isNew = !cachedSessionKeys.has(sessionKey); + const serverUserId = sessionServerUserIds[i]; + if (isNew && serverUserId) { + serverUsersWithNewSessions.add(serverUserId); + } + } + + const recentSessionsMap = await batchGetRecentUserSessions([...serverUsersWithNewSessions]); + + // Process each session + for (let i = 0; i < processedSessions.length; i++) { + const processed = processedSessions[i]!; + const sessionKey = `${server.id}:${processed.sessionKey}`; + currentSessionKeys.add(sessionKey); + + const serverUserId = sessionServerUserIds[i]; + if (!serverUserId) { + console.error('Failed to get/create server user for session'); + continue; + } + + // Get server user details from cache + const serverUserFromCache = serverUserById.get(serverUserId); + const userDetail = serverUserFromCache + ? { + id: serverUserFromCache.id, + username: serverUserFromCache.username, + thumbUrl: serverUserFromCache.thumbUrl, + identityName: serverUserFromCache.identityName, + } + : { id: serverUserId, username: 'Unknown', thumbUrl: null, identityName: null }; + + // Get GeoIP location + const geo: GeoLocation = geoipService.lookup(processed.ipAddress); + + const isNew = !cachedSessionKeys.has(sessionKey); + + if (isNew) { + // RACE CONDITION CHECK: Verify no active session exists with this sessionKey + // This can happen when SSE and poller both try to create a session simultaneously + const existingWithSameKey = await db + .select({ id: sessions.id }) + .from(sessions) + .where( + and( + eq(sessions.serverId, server.id), + eq(sessions.sessionKey, processed.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + if (existingWithSameKey.length > 0) { + // Session already exists (likely created by SSE), skip insert + // Add to cache so we don't check again next poll + cachedSessionKeys.add(sessionKey); + console.log(`[Poller] Active session already exists for ${processed.sessionKey}, skipping create`); + continue; + } + + // Check for session grouping - find recent unfinished session with same serverUser+ratingKey + let referenceId: string | null = null; + + // FIRST: Check if there's an ACTIVE session for the same user+content + // This handles quality changes mid-stream where Plex assigns a new sessionKey + if (processed.ratingKey) { + const activeSameContent = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverUserId, serverUserId), + eq(sessions.ratingKey, processed.ratingKey), + isNull(sessions.stoppedAt) // Still active + ) + ) + .orderBy(desc(sessions.startedAt)) + .limit(1); + + const existingActiveSession = activeSameContent[0]; + if (existingActiveSession) { + // This is a quality/resolution change during playback + // Stop the old session and link the new one + const now = new Date(); + const { durationMs, finalPausedDurationMs } = calculateStopDuration( + { + startedAt: existingActiveSession.startedAt, + lastPausedAt: existingActiveSession.lastPausedAt, + pausedDurationMs: existingActiveSession.pausedDurationMs || 0, + }, + now + ); + + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt: now, + durationMs, + pausedDurationMs: finalPausedDurationMs, + lastPausedAt: null, + // Keep watched=false since playback is continuing + }) + .where(eq(sessions.id, existingActiveSession.id)); + + // Remove from cache + if (cacheService) { + await cacheService.deleteSessionById(existingActiveSession.id); + await cacheService.removeUserSession(existingActiveSession.serverUserId, existingActiveSession.id); + } + + // Publish stop event for the old session + if (pubSubService) { + await pubSubService.publish('session:stopped', existingActiveSession.id); + } + + // Remove from cached session keys to prevent "stale" detection for this server + cachedSessionKeys.delete(`${server.id}:${existingActiveSession.sessionKey}`); + + // Link to the original session chain + referenceId = existingActiveSession.referenceId || existingActiveSession.id; + + console.log(`[Poller] Quality change detected for user ${serverUserId}, content ${processed.ratingKey}. Old session ${existingActiveSession.id} stopped, linking new session.`); + } + } + + // SECOND: Check for recently stopped sessions (resume tracking) + if (!referenceId && processed.ratingKey) { + const oneDayAgo = new Date(Date.now() - TIME_MS.DAY); + const recentSameContent = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverUserId, serverUserId), + eq(sessions.ratingKey, processed.ratingKey), + gte(sessions.stoppedAt, oneDayAgo), + eq(sessions.watched, false) // Not fully watched + ) + ) + .orderBy(desc(sessions.stoppedAt)) + .limit(1); + + const previousSession = recentSameContent[0]; + // If user is resuming (progress >= previous), link to the original session + if (previousSession && processed.progressMs !== undefined) { + const prevProgress = previousSession.progressMs || 0; + if (processed.progressMs >= prevProgress) { + // This is a "resume" - link to the first session in the chain + referenceId = previousSession.referenceId || previousSession.id; + } + } + } + + // Use transaction to ensure session insert + rule evaluation + violation creation are atomic + // This prevents orphaned sessions without violations on crash + const recentSessions = recentSessionsMap.get(serverUserId) ?? []; + + const { insertedSession, violationResults } = await db.transaction(async (tx) => { + // Insert new session with pause tracking fields + const insertedRows = await tx + .insert(sessions) + .values({ + serverId: server.id, + serverUserId, + sessionKey: processed.sessionKey, + plexSessionId: processed.plexSessionId || null, + ratingKey: processed.ratingKey || null, + state: processed.state, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + // Enhanced media metadata + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + startedAt: new Date(), + lastSeenAt: new Date(), // Track when we first saw this session + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + // Pause tracking - use Jellyfin's precise timestamp if available, otherwise infer from state + lastPausedAt: processed.lastPausedDate ?? (processed.state === 'paused' ? new Date() : null), + pausedDurationMs: 0, + // Session grouping + referenceId, + watched: false, + // Network/device info + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + }) + .returning(); + + const inserted = insertedRows[0]; + if (!inserted) { + throw new Error('Failed to insert session'); + } + + // Evaluate rules within same transaction + const session = { + id: inserted.id, + serverId: server.id, + serverUserId, + sessionKey: processed.sessionKey, + state: processed.state, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + ratingKey: processed.ratingKey || null, + externalSessionId: null, + startedAt: inserted.startedAt, + stoppedAt: null, + durationMs: null, + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + lastPausedAt: inserted.lastPausedAt, + pausedDurationMs: inserted.pausedDurationMs, + referenceId: inserted.referenceId, + watched: inserted.watched, + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + }; + + const ruleResults = await ruleEngine.evaluateSession(session, activeRules, recentSessions); + + // Create violations within same transaction + const createdViolations: ViolationInsertResult[] = []; + for (const result of ruleResults) { + if (result.violated) { + const matchingRule = activeRules.find( + (r) => doesRuleApplyToUser(r, serverUserId) + ); + if (matchingRule) { + // Check for duplicate violations before creating + // This prevents multiple violations when sessions start simultaneously + const relatedSessionIds = (result.data?.relatedSessionIds as string[]) || []; + const isDuplicate = await isDuplicateViolation( + serverUserId, + matchingRule.type, + inserted.id, + relatedSessionIds + ); + + if (isDuplicate) { + continue; // Skip creating duplicate violation + } + + const violationResult = await createViolationInTransaction( + tx, + matchingRule.id, + serverUserId, + inserted.id, + result, + matchingRule + ); + createdViolations.push(violationResult); + } + } + } + + return { insertedSession: inserted, violationResults: createdViolations }; + }); + + // Build active session for cache/broadcast (outside transaction - read only) + const activeSession: ActiveSession = { + id: insertedSession.id, + serverId: server.id, + serverUserId, + sessionKey: processed.sessionKey, + state: processed.state, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + // Enhanced media metadata + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + ratingKey: processed.ratingKey || null, + externalSessionId: null, + startedAt: insertedSession.startedAt, + stoppedAt: null, + durationMs: null, + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + // Pause tracking + lastPausedAt: insertedSession.lastPausedAt, + pausedDurationMs: insertedSession.pausedDurationMs, + referenceId: insertedSession.referenceId, + watched: insertedSession.watched, + // Network/device info + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + user: userDetail, + server: { id: server.id, name: server.name, type: server.type }, + }; + + newSessions.push(activeSession); + + // Broadcast violations AFTER transaction commits (outside transaction) + // Wrapped in try-catch to prevent broadcast failures from crashing the poller + try { + await broadcastViolations(violationResults, insertedSession.id, pubSubService); + } catch (err) { + console.error('[Poller] Failed to broadcast violations:', err); + // Violations are already persisted in DB, broadcast failure is non-fatal + } + } else { + // Get existing ACTIVE session to check for state changes + const existingRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, server.id), + eq(sessions.sessionKey, processed.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + const existingSession = existingRows[0]; + if (!existingSession) continue; + + const previousState = existingSession.state; + const newState = processed.state; + const now = new Date(); + + // Build update payload with pause tracking + const updatePayload: { + state: 'playing' | 'paused'; + quality: string; + bitrate: number; + progressMs: number | null; + lastSeenAt: Date; + plexSessionId?: string | null; + lastPausedAt?: Date | null; + pausedDurationMs?: number; + watched?: boolean; + } = { + state: newState, + quality: processed.quality, + bitrate: processed.bitrate, + progressMs: processed.progressMs || null, + lastSeenAt: now, // Track when we last saw this session (for stale detection) + // Always update plexSessionId (backfills sessions created before migration 0012) + plexSessionId: processed.plexSessionId || null, + }; + + // Handle state transitions for pause tracking + const pauseResult = calculatePauseAccumulation( + previousState as SessionState, + newState, + { lastPausedAt: existingSession.lastPausedAt, pausedDurationMs: existingSession.pausedDurationMs || 0 }, + now + ); + updatePayload.lastPausedAt = pauseResult.lastPausedAt; + updatePayload.pausedDurationMs = pauseResult.pausedDurationMs; + + // Check for watch completion (80% threshold) + if (!existingSession.watched && checkWatchCompletion(processed.progressMs, processed.totalDurationMs)) { + updatePayload.watched = true; + } + + // Update existing session with state changes and pause tracking + await db + .update(sessions) + .set(updatePayload) + .where(eq(sessions.id, existingSession.id)); + + // Build active session for cache/broadcast (with updated pause tracking values) + const activeSession: ActiveSession = { + id: existingSession.id, + serverId: server.id, + serverUserId, + sessionKey: processed.sessionKey, + state: newState, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + // Enhanced media metadata + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + ratingKey: processed.ratingKey || null, + externalSessionId: existingSession.externalSessionId, + startedAt: existingSession.startedAt, + stoppedAt: null, + durationMs: null, + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + // Pause tracking - use updated values + lastPausedAt: updatePayload.lastPausedAt ?? existingSession.lastPausedAt, + pausedDurationMs: updatePayload.pausedDurationMs ?? existingSession.pausedDurationMs ?? 0, + referenceId: existingSession.referenceId, + watched: updatePayload.watched ?? existingSession.watched ?? false, + // Network/device info + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + user: userDetail, + server: { id: server.id, name: server.name, type: server.type }, + }; + updatedSessions.push(activeSession); + } + } + + // Find stopped sessions + const stoppedSessionKeys: string[] = []; + for (const cachedKey of cachedSessionKeys) { + if (cachedKey.startsWith(`${server.id}:`) && !currentSessionKeys.has(cachedKey)) { + stoppedSessionKeys.push(cachedKey); + + // Mark session as stopped in database + const sessionKey = cachedKey.replace(`${server.id}:`, ''); + const stoppedRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, server.id), + eq(sessions.sessionKey, sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + const stoppedSession = stoppedRows[0]; + if (stoppedSession) { + const stoppedAt = new Date(); + + // Calculate final duration + const { durationMs, finalPausedDurationMs } = calculateStopDuration( + { + startedAt: stoppedSession.startedAt, + lastPausedAt: stoppedSession.lastPausedAt, + pausedDurationMs: stoppedSession.pausedDurationMs || 0, + }, + stoppedAt + ); + + // Check for watch completion + const watched = stoppedSession.watched || checkWatchCompletion( + stoppedSession.progressMs, + stoppedSession.totalDurationMs + ); + + // Check if session meets minimum play time threshold (default 120s) + // Short sessions are recorded but can be filtered from stats + const shortSession = !shouldRecordSession(durationMs); + + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt, + durationMs, + pausedDurationMs: finalPausedDurationMs, + lastPausedAt: null, // Clear the pause timestamp + watched, + shortSession, + }) + .where(eq(sessions.id, stoppedSession.id)); + } + } + } + + return { success: true, newSessions, stoppedSessionKeys, updatedSessions }; + } catch (error) { + console.error(`Error polling server ${server.name}:`, error); + return { success: false, newSessions: [], stoppedSessionKeys: [], updatedSessions: [] }; + } +} + +// ============================================================================ +// Main Polling Orchestration +// ============================================================================ + +/** + * Poll all connected servers for active sessions + * + * With SSE integration: + * - Plex servers with active SSE connections are skipped (handled by SSE) + * - Plex servers in fallback mode are polled + * - Jellyfin/Emby servers are always polled (no SSE support) + */ +async function pollServers(): Promise { + try { + // Get all connected servers + const allServers = await db.select().from(servers); + + if (allServers.length === 0) { + return; + } + + // Filter to only servers that need polling + // SSE-connected Plex servers are handled by SSE, not polling + const serversNeedingPoll = allServers.filter((server) => { + // Non-Plex servers always need polling (Jellyfin/Emby don't support SSE yet) + if (server.type !== 'plex') { + return true; + } + // Plex servers in fallback mode need polling + return sseManager.isInFallback(server.id); + }); + + if (serversNeedingPoll.length === 0) { + // All Plex servers are connected via SSE, no polling needed + return; + } + + // Get cached session keys from atomic SET-based cache + const cachedSessions = cacheService ? await cacheService.getAllActiveSessions() : []; + const cachedSessionKeys = new Set( + cachedSessions.map((s) => `${s.serverId}:${s.sessionKey}`) + ); + + // Get active rules + const activeRules = await getActiveRules(); + + // Collect results from all servers + const allNewSessions: ActiveSession[] = []; + const allStoppedKeys: string[] = []; + const allUpdatedSessions: ActiveSession[] = []; + + // Process each server with health tracking + for (const server of serversNeedingPoll) { + const serverWithToken = server as ServerWithToken; + + // Get previous health state for transition detection + const wasHealthy = cacheService ? await cacheService.getServerHealth(server.id) : null; + + const { success, newSessions, stoppedSessionKeys, updatedSessions } = await processServerSessions( + serverWithToken, + activeRules, + cachedSessionKeys + ); + + // Track health state and notify on transitions + if (cacheService) { + await cacheService.setServerHealth(server.id, success); + + // Detect health state transitions + if (wasHealthy === true && !success) { + // Server went down - notify + console.log(`[Poller] Server ${server.name} is DOWN`); + await enqueueNotification({ + type: 'server_down', + payload: { serverName: server.name, serverId: server.id }, + }); + } else if (wasHealthy === false && success) { + // Server came back up - notify + console.log(`[Poller] Server ${server.name} is back UP`); + await enqueueNotification({ + type: 'server_up', + payload: { serverName: server.name, serverId: server.id }, + }); + } + // wasHealthy === null means first poll, don't notify + } + + allNewSessions.push(...newSessions); + allStoppedKeys.push(...stoppedSessionKeys); + allUpdatedSessions.push(...updatedSessions); + } + + // Update cache incrementally (preserves concurrent SSE operations) + if (cacheService) { + // Extract stopped session IDs from the key format "serverId:sessionKey" + const stoppedSessionIds: string[] = []; + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + stoppedSessionIds.push(stoppedSession.id); + } + } + } + + // Incremental sync: adds new, removes stopped, updates existing + await cacheService.incrementalSyncActiveSessions( + allNewSessions, + stoppedSessionIds, + allUpdatedSessions + ); + + // Update user session sets for new sessions + for (const session of allNewSessions) { + await cacheService.addUserSession(session.serverUserId, session.id); + } + + // Remove stopped sessions from user session sets + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + await cacheService.removeUserSession(stoppedSession.serverUserId, stoppedSession.id); + } + } + } + } + + // Publish events via pub/sub + if (pubSubService) { + for (const session of allNewSessions) { + await pubSubService.publish('session:started', session); + // Enqueue notification for async dispatch + await enqueueNotification({ type: 'session_started', payload: session }); + } + + for (const session of allUpdatedSessions) { + await pubSubService.publish('session:updated', session); + } + + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions?.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + await pubSubService.publish('session:stopped', stoppedSession.id); + // Enqueue notification for async dispatch + await enqueueNotification({ type: 'session_stopped', payload: stoppedSession }); + } + } + } + } + + if (allNewSessions.length > 0 || allStoppedKeys.length > 0) { + console.log( + `Poll complete: ${allNewSessions.length} new, ${allUpdatedSessions.length} updated, ${allStoppedKeys.length} stopped` + ); + } + + // Sweep for stale sessions that haven't been seen in a while + // This catches sessions where server went down or SSE missed the stop event + await sweepStaleSessions(); + } catch (error) { + console.error('Polling error:', error); + } +} + +// ============================================================================ +// Stale Session Detection +// ============================================================================ + +/** + * Sweep for stale sessions and force-stop them + * + * A session is considered stale when: + * - It hasn't been stopped (stoppedAt IS NULL) + * - It hasn't been seen in a poll for > STALE_SESSION_TIMEOUT_SECONDS (default 5 min) + * + * This catches sessions where: + * - Server became unreachable during playback + * - SSE connection dropped and we missed the stop event + * - The session hung on the media server side + * + * Stale sessions are marked with forceStopped = true to distinguish from normal stops. + * Sessions with insufficient play time (< MIN_PLAY_TIME_MS) are still recorded for + * audit purposes but can be filtered from stats queries. + */ +export async function sweepStaleSessions(): Promise { + try { + // Calculate the stale threshold (sessions not seen in last 5 minutes) + const staleThreshold = new Date( + Date.now() - SESSION_LIMITS.STALE_SESSION_TIMEOUT_SECONDS * 1000 + ); + + // Find all active sessions that haven't been seen recently + const staleSessions = await db + .select() + .from(sessions) + .where( + and( + isNull(sessions.stoppedAt), // Still active + lte(sessions.lastSeenAt, staleThreshold) // Not seen recently + ) + ); + + if (staleSessions.length === 0) { + return 0; + } + + console.log(`[Poller] Force-stopping ${staleSessions.length} stale session(s)`); + + const now = new Date(); + + for (const staleSession of staleSessions) { + // Check if session should be force-stopped (using the stateTracker function) + if (!shouldForceStopStaleSession(staleSession.lastSeenAt)) { + // Shouldn't happen since we already filtered, but double-check + continue; + } + + // Calculate final duration + const { durationMs, finalPausedDurationMs } = calculateStopDuration( + { + startedAt: staleSession.startedAt, + lastPausedAt: staleSession.lastPausedAt, + pausedDurationMs: staleSession.pausedDurationMs || 0, + }, + now + ); + + // Check for watch completion + const watched = + staleSession.watched || + checkWatchCompletion(staleSession.progressMs, staleSession.totalDurationMs); + + // Check if session meets minimum play time threshold + const shortSession = !shouldRecordSession(durationMs); + + // Force-stop the session + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt: now, + durationMs, + pausedDurationMs: finalPausedDurationMs, + lastPausedAt: null, + watched, + forceStopped: true, // Mark as force-stopped + shortSession, + }) + .where(eq(sessions.id, staleSession.id)); + + // Remove from cache if cached + if (cacheService) { + await cacheService.deleteSessionById(staleSession.id); + await cacheService.removeUserSession(staleSession.serverUserId, staleSession.id); + } + + // Publish stop event + if (pubSubService) { + await pubSubService.publish('session:stopped', staleSession.id); + } + } + + // Invalidate dashboard stats after force-stopping sessions + if (redisClient) { + await redisClient.del(REDIS_KEYS.DASHBOARD_STATS); + } + + return staleSessions.length; + } catch (error) { + console.error('[Poller] Error sweeping stale sessions:', error); + return 0; + } +} + +// ============================================================================ +// Lifecycle Management +// ============================================================================ + +/** + * Initialize the poller with cache services and Redis client + */ +export function initializePoller(cache: CacheService, pubSub: PubSubService, redis: Redis): void { + cacheService = cache; + pubSubService = pubSub; + redisClient = redis; +} + +/** + * Start the polling job + */ +export function startPoller(config: Partial = {}): void { + const mergedConfig = { ...defaultConfig, ...config }; + + if (!mergedConfig.enabled) { + console.log('Session poller disabled'); + return; + } + + if (pollingInterval) { + console.log('Poller already running'); + return; + } + + console.log(`Starting session poller with ${mergedConfig.intervalMs}ms interval`); + + // Run immediately on start + void pollServers(); + + // Then run on interval + pollingInterval = setInterval(() => void pollServers(), mergedConfig.intervalMs); + + // Start stale session sweep (runs every 60 seconds to detect abandoned sessions) + if (!staleSweepInterval) { + console.log(`Starting stale session sweep with ${SESSION_LIMITS.STALE_SWEEP_INTERVAL_MS}ms interval`); + staleSweepInterval = setInterval(() => void sweepStaleSessions(), SESSION_LIMITS.STALE_SWEEP_INTERVAL_MS); + } +} + +/** + * Stop the polling job + */ +export function stopPoller(): void { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + console.log('Session poller stopped'); + } + if (staleSweepInterval) { + clearInterval(staleSweepInterval); + staleSweepInterval = null; + console.log('Stale session sweep stopped'); + } +} + +/** + * Force an immediate poll + */ +export async function triggerPoll(): Promise { + await pollServers(); +} + +/** + * Reconciliation poll for SSE-connected servers + * + * This is a lighter poll that runs periodically to catch any events + * that might have been missed by SSE. Only polls Plex servers that + * have active SSE connections (not in fallback mode). + * + * Unlike the main poller, this processes results and updates the cache + * to sync any sessions that SSE may have missed. + */ +export async function triggerReconciliationPoll(): Promise { + try { + // Get all Plex servers with active SSE connections + const allServers = await db.select().from(servers); + const sseServers = allServers.filter( + (server) => server.type === 'plex' && !sseManager.isInFallback(server.id) + ); + + if (sseServers.length === 0) { + return; + } + + console.log(`[Poller] Running reconciliation poll for ${sseServers.length} SSE-connected server(s)`); + + // Get cached session keys from atomic SET-based cache + const cachedSessions = cacheService ? await cacheService.getAllActiveSessions() : []; + const cachedSessionKeys = new Set( + cachedSessions.map((s) => `${s.serverId}:${s.sessionKey}`) + ); + + // Get active rules + const activeRules = await getActiveRules(); + + // Collect results from all SSE servers + const allNewSessions: ActiveSession[] = []; + const allStoppedKeys: string[] = []; + const allUpdatedSessions: ActiveSession[] = []; + + // Process each SSE server and collect results + for (const server of sseServers) { + const serverWithToken = server as ServerWithToken; + const { newSessions, stoppedSessionKeys, updatedSessions } = await processServerSessions( + serverWithToken, + activeRules, + cachedSessionKeys + ); + allNewSessions.push(...newSessions); + allStoppedKeys.push(...stoppedSessionKeys); + allUpdatedSessions.push(...updatedSessions); + } + + // Update cache incrementally if there were any changes + if (cacheService && (allNewSessions.length > 0 || allStoppedKeys.length > 0 || allUpdatedSessions.length > 0)) { + // Extract stopped session IDs from the key format "serverId:sessionKey" + const stoppedSessionIds: string[] = []; + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + stoppedSessionIds.push(stoppedSession.id); + } + } + } + + // Incremental sync: adds new, removes stopped, updates existing + await cacheService.incrementalSyncActiveSessions( + allNewSessions, + stoppedSessionIds, + allUpdatedSessions + ); + + // Update user session sets for new sessions + for (const session of allNewSessions) { + await cacheService.addUserSession(session.serverUserId, session.id); + } + + // Remove stopped sessions from user session sets + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + await cacheService.removeUserSession(stoppedSession.serverUserId, stoppedSession.id); + } + } + } + + console.log( + `[Poller] Reconciliation complete: ${allNewSessions.length} new, ${allUpdatedSessions.length} updated, ${allStoppedKeys.length} stopped` + ); + } + + // Publish events via pub/sub for any changes + if (pubSubService) { + for (const session of allNewSessions) { + await pubSubService.publish('session:started', session); + await enqueueNotification({ type: 'session_started', payload: session }); + } + + for (const session of allUpdatedSessions) { + await pubSubService.publish('session:updated', session); + } + + for (const key of allStoppedKeys) { + const parts = key.split(':'); + if (parts.length >= 2) { + const serverId = parts[0]; + const sessionKey = parts.slice(1).join(':'); + const stoppedSession = cachedSessions.find( + (s) => s.serverId === serverId && s.sessionKey === sessionKey + ); + if (stoppedSession) { + await pubSubService.publish('session:stopped', stoppedSession.id); + await enqueueNotification({ type: 'session_stopped', payload: stoppedSession }); + } + } + } + } + } catch (error) { + console.error('[Poller] Reconciliation poll error:', error); + } +} diff --git a/apps/server/src/jobs/poller/sessionMapper.ts b/apps/server/src/jobs/poller/sessionMapper.ts new file mode 100644 index 0000000..b888cff --- /dev/null +++ b/apps/server/src/jobs/poller/sessionMapper.ts @@ -0,0 +1,222 @@ +/** + * Session Mapping Functions + * + * Functions to transform sessions between different formats: + * - MediaSession (from mediaServer adapter) → ProcessedSession (for DB storage) + * - Database row → Session type (for application use) + */ + +import type { Session } from '@tracearr/shared'; +import type { MediaSession } from '../../services/mediaServer/types.js'; +import type { ProcessedSession } from './types.js'; +import { parseJellyfinClient } from './utils.js'; +import type { sessions } from '../../db/schema.js'; + +// ============================================================================ +// Quality Formatting +// ============================================================================ + +/** + * Format video resolution to a user-friendly string + * Normalizes various resolution formats to standard labels + * + * @param resolution - Raw resolution string from API (e.g., "4k", "1080", "720p", "sd") + * @param height - Video height in pixels as fallback + * @returns Formatted resolution string (e.g., "4K", "1080p", "720p", "SD") + */ +function formatResolution(resolution?: string, height?: number): string | null { + if (resolution) { + const lower = resolution.toLowerCase(); + // Handle "4k" or "2160" resolution + if (lower === '4k' || lower === '2160' || lower === '2160p') { + return '4K'; + } + // Handle standard numeric resolutions + if (lower === '1080' || lower === '1080p') { + return '1080p'; + } + if (lower === '720' || lower === '720p') { + return '720p'; + } + if (lower === '480' || lower === '480p') { + return '480p'; + } + if (lower === 'sd') { + return 'SD'; + } + // Return the original with 'p' suffix if it looks numeric + if (/^\d+$/.test(lower)) { + return `${lower}p`; + } + // Return as-is if it's already formatted (e.g., "1080p") + return resolution; + } + + // Fall back to height-based resolution + if (height) { + if (height >= 2160) return '4K'; + if (height >= 1080) return '1080p'; + if (height >= 720) return '720p'; + if (height >= 480) return '480p'; + return 'SD'; + } + + return null; +} + +/** + * Build quality display string from session quality data + * Prefers resolution over bitrate for clarity + * + * @param quality - Session quality object from MediaSession + * @returns Quality string for display (e.g., "4K", "1080p", "54Mbps", "Direct") + */ +function formatQualityString(quality: MediaSession['quality']): string { + // Prefer resolution-based display + const resolution = formatResolution(quality.videoResolution, quality.videoHeight); + if (resolution) { + return resolution; + } + + // Fall back to bitrate if available + if (quality.bitrate > 0) { + return `${Math.round(quality.bitrate / 1000)}Mbps`; + } + + // Last resort: transcode status + return quality.isTranscode ? 'Transcoding' : 'Direct'; +} + +// ============================================================================ +// MediaSession → ProcessedSession Mapping +// ============================================================================ + +/** + * Map unified MediaSession to ProcessedSession format + * Works for both Plex and Jellyfin sessions from the new adapter + * + * @param session - Unified MediaSession from the mediaServer adapter + * @param serverType - Type of media server ('plex' | 'jellyfin') + * @returns ProcessedSession ready for database storage + * + * @example + * const processed = mapMediaSession(mediaSession, 'plex'); + * // Use processed for DB insert + */ +export function mapMediaSession( + session: MediaSession, + serverType: 'plex' | 'jellyfin' | 'emby' +): ProcessedSession { + const isEpisode = session.media.type === 'episode'; + + // For episodes, prefer show poster; for movies, use media poster + const thumbPath = isEpisode && session.episode?.showThumbPath + ? session.episode.showThumbPath + : session.media.thumbPath ?? ''; + + // Build quality string from resolution (preferred) or bitrate + const quality = formatQualityString(session.quality); + + // Keep the IP address - GeoIP service handles private IPs correctly + const ipAddress = session.network.ipAddress; + + // Get platform/device - Jellyfin and Emby may need client string parsing + let platform = session.player.platform ?? ''; + let device = session.player.device ?? ''; + if ((serverType === 'jellyfin' || serverType === 'emby') && session.player.product) { + const parsed = parseJellyfinClient(session.player.product, device); + platform = platform || parsed.platform; + device = device || parsed.device; + } + + return { + sessionKey: session.sessionKey, + plexSessionId: session.plexSessionId, + ratingKey: session.mediaId, + // User data + externalUserId: session.user.id, + username: session.user.username || 'Unknown', + userThumb: session.user.thumb ?? '', + mediaTitle: session.media.title, + mediaType: session.media.type === 'movie' ? 'movie' + : session.media.type === 'episode' ? 'episode' + : 'track', + // Enhanced media metadata + grandparentTitle: session.episode?.showTitle ?? '', + seasonNumber: session.episode?.seasonNumber ?? 0, + episodeNumber: session.episode?.episodeNumber ?? 0, + year: session.media.year ?? 0, + thumbPath, + // Connection info + ipAddress, + playerName: session.player.name, + deviceId: session.player.deviceId, + product: session.player.product ?? '', + device, + platform, + quality, + isTranscode: session.quality.isTranscode, + bitrate: session.quality.bitrate, + state: session.playback.state === 'paused' ? 'paused' : 'playing', + totalDurationMs: session.media.durationMs, + progressMs: session.playback.positionMs, + // Jellyfin provides exact pause timestamp for more accurate tracking + lastPausedDate: session.lastPausedDate, + }; +} + +// ============================================================================ +// Database Row → Session Mapping +// ============================================================================ + +/** + * Map a database session row to the Session type + * + * @param s - Database session row from drizzle select + * @returns Session object for application use + * + * @example + * const rows = await db.select().from(sessions).where(...); + * const sessionObjects = rows.map(mapSessionRow); + */ +export function mapSessionRow(s: typeof sessions.$inferSelect): Session { + return { + id: s.id, + serverId: s.serverId, + serverUserId: s.serverUserId, + sessionKey: s.sessionKey, + state: s.state, + mediaType: s.mediaType, + mediaTitle: s.mediaTitle, + grandparentTitle: s.grandparentTitle, + seasonNumber: s.seasonNumber, + episodeNumber: s.episodeNumber, + year: s.year, + thumbPath: s.thumbPath, + ratingKey: s.ratingKey, + externalSessionId: s.externalSessionId, + startedAt: s.startedAt, + stoppedAt: s.stoppedAt, + durationMs: s.durationMs, + totalDurationMs: s.totalDurationMs, + progressMs: s.progressMs, + lastPausedAt: s.lastPausedAt, + pausedDurationMs: s.pausedDurationMs, + referenceId: s.referenceId, + watched: s.watched, + ipAddress: s.ipAddress, + geoCity: s.geoCity, + geoRegion: s.geoRegion, + geoCountry: s.geoCountry, + geoLat: s.geoLat, + geoLon: s.geoLon, + playerName: s.playerName, + deviceId: s.deviceId, + product: s.product, + device: s.device, + platform: s.platform, + quality: s.quality, + isTranscode: s.isTranscode, + bitrate: s.bitrate, + }; +} diff --git a/apps/server/src/jobs/poller/stateTracker.ts b/apps/server/src/jobs/poller/stateTracker.ts new file mode 100644 index 0000000..43ab0c5 --- /dev/null +++ b/apps/server/src/jobs/poller/stateTracker.ts @@ -0,0 +1,295 @@ +/** + * Session State Tracking + * + * Pure functions for tracking session state transitions, pause accumulation, + * watch completion, and session grouping (resume detection). + */ + +import { SESSION_LIMITS, type SessionState } from '@tracearr/shared'; +import type { PauseAccumulationResult, StopDurationResult, SessionPauseData } from './types.js'; + +// ============================================================================ +// Pause Tracking +// ============================================================================ + +/** + * Calculate pause accumulation when session state changes. + * Handles transitions between playing and paused states. + * + * @param previousState - Previous playback state + * @param newState - New playback state + * @param existingSession - Current session pause data + * @param now - Current timestamp + * @returns Updated pause tracking data + * + * @example + * // Starting to pause + * calculatePauseAccumulation('playing', 'paused', { lastPausedAt: null, pausedDurationMs: 0 }, now); + * // Returns: { lastPausedAt: now, pausedDurationMs: 0 } + * + * // Resuming playback after 5 minutes paused + * calculatePauseAccumulation('paused', 'playing', { lastPausedAt: fiveMinutesAgo, pausedDurationMs: 0 }, now); + * // Returns: { lastPausedAt: null, pausedDurationMs: 300000 } + */ +export function calculatePauseAccumulation( + previousState: SessionState, + newState: SessionState, + existingSession: { lastPausedAt: Date | null; pausedDurationMs: number }, + now: Date +): PauseAccumulationResult { + let lastPausedAt = existingSession.lastPausedAt; + let pausedDurationMs = existingSession.pausedDurationMs; + + if (previousState === 'playing' && newState === 'paused') { + // Started pausing - record timestamp + lastPausedAt = now; + } else if (previousState === 'paused' && newState === 'playing') { + // Resumed playing - accumulate pause duration + if (existingSession.lastPausedAt) { + const pausedMs = now.getTime() - existingSession.lastPausedAt.getTime(); + pausedDurationMs = (existingSession.pausedDurationMs || 0) + pausedMs; + } + lastPausedAt = null; + } + + return { lastPausedAt, pausedDurationMs }; +} + +/** + * Calculate final duration when a session is stopped. + * Accounts for any remaining pause time if stopped while paused. + * + * @param session - Session pause tracking data + * @param stoppedAt - Timestamp when session stopped + * @returns Actual watch duration and final paused duration + * + * @example + * // Session that was playing when stopped + * calculateStopDuration({ startedAt: tenMinutesAgo, lastPausedAt: null, pausedDurationMs: 60000 }, now); + * // Returns: { durationMs: 540000, finalPausedDurationMs: 60000 } (9 min watch, 1 min paused) + * + * // Session that was paused when stopped (adds remaining pause time) + * calculateStopDuration({ startedAt: tenMinutesAgo, lastPausedAt: twoMinutesAgo, pausedDurationMs: 60000 }, now); + * // Returns: { durationMs: 420000, finalPausedDurationMs: 180000 } (7 min watch, 3 min paused) + */ +export function calculateStopDuration( + session: SessionPauseData, + stoppedAt: Date +): StopDurationResult { + const totalElapsedMs = stoppedAt.getTime() - session.startedAt.getTime(); + + // Calculate final paused duration - accumulate any remaining pause if stopped while paused + let finalPausedDurationMs = session.pausedDurationMs || 0; + if (session.lastPausedAt) { + // Session was stopped while paused - add the remaining pause time + finalPausedDurationMs += stoppedAt.getTime() - session.lastPausedAt.getTime(); + } + + // Calculate actual watch duration (excludes all paused time) + const durationMs = Math.max(0, totalElapsedMs - finalPausedDurationMs); + + return { durationMs, finalPausedDurationMs }; +} + +// ============================================================================ +// Stale Session Detection +// ============================================================================ + +/** + * Determine if a session should be force-stopped due to inactivity. + * A session is considered stale when no updates have been received for the timeout period. + * + * @param lastSeenAt - Last update timestamp for the session + * @param timeoutSeconds - Optional custom timeout in seconds, defaults to 5 minutes + * @returns true if the session should be force-stopped + * + * @example + * // Session last seen 6 minutes ago + * shouldForceStopStaleSession(sixMinutesAgo); // true + * + * // Session last seen 3 minutes ago + * shouldForceStopStaleSession(threeMinutesAgo); // false + * + * // Exactly at threshold (5 minutes) - NOT stale yet + * shouldForceStopStaleSession(fiveMinutesAgo); // false + */ +export function shouldForceStopStaleSession( + lastSeenAt: Date, + timeoutSeconds: number = SESSION_LIMITS.STALE_SESSION_TIMEOUT_SECONDS +): boolean { + const elapsedMs = Date.now() - lastSeenAt.getTime(); + const timeoutMs = timeoutSeconds * 1000; + // Strictly greater than - at exactly the threshold, session is NOT stale yet + return elapsedMs > timeoutMs; +} + +// ============================================================================ +// Minimum Play Time Filtering +// ============================================================================ + +/** + * Determine if a session should be recorded based on minimum play time. + * Sessions shorter than the threshold are filtered out to reduce noise. + * + * @param durationMs - Session duration in milliseconds + * @param minPlayTimeMs - Optional custom minimum play time, defaults to 2 minutes + * @returns true if the session should be recorded + * + * @example + * shouldRecordSession(60 * 1000); // false (1 min < 2 min threshold) + * shouldRecordSession(120 * 1000); // true (exactly 2 min threshold) + * shouldRecordSession(180 * 1000); // true (3 min > threshold) + * shouldRecordSession(1000, 0); // true (no minimum when 0) + */ +export function shouldRecordSession( + durationMs: number, + minPlayTimeMs: number = SESSION_LIMITS.MIN_PLAY_TIME_MS +): boolean { + // If minimum is 0, always record + if (minPlayTimeMs === 0) return true; + return durationMs >= minPlayTimeMs; +} + +// ============================================================================ +// Watch Completion +// ============================================================================ + +/** + * Check if a session should be marked as "watched" based on progress threshold. + * + * @param progressMs - Current playback position in milliseconds + * @param totalDurationMs - Total media duration in milliseconds + * @param threshold - Optional custom threshold (0-1), defaults to 85% + * @returns true if watched at least the threshold percentage of the content + * + * @example + * checkWatchCompletion(8500, 10000); // true (85% with default threshold) + * checkWatchCompletion(8000, 10000); // false (80% < 85% default) + * checkWatchCompletion(9000, 10000, 0.90); // true (90% with custom threshold) + * checkWatchCompletion(null, 6000000); // false (no progress) + */ +export function checkWatchCompletion( + progressMs: number | null, + totalDurationMs: number | null, + threshold: number = SESSION_LIMITS.WATCH_COMPLETION_THRESHOLD +): boolean { + if (!progressMs || !totalDurationMs) return false; + return (progressMs / totalDurationMs) >= threshold; +} + +// ============================================================================ +// Quality Change Detection +// ============================================================================ + +/** + * Determine if a new session represents a quality/resolution change during playback. + * This happens when Plex/Jellyfin assigns a new sessionKey but the user is still + * watching the same content. + * + * @param existingActiveSession - Active (not stopped) session for same user+content, or null + * @returns referenceId to link to if this is a quality change, or null + * + * @example + * // Quality change detected - link to existing session + * isQualityChangeScenario({ id: 'sess-1', referenceId: null, stoppedAt: null }); + * // Returns: 'sess-1' + * + * // Quality change with existing chain - link to original + * isQualityChangeScenario({ id: 'sess-2', referenceId: 'sess-1', stoppedAt: null }); + * // Returns: 'sess-1' + * + * // Session already stopped - not a quality change + * isQualityChangeScenario({ id: 'sess-1', referenceId: null, stoppedAt: new Date() }); + * // Returns: null + * + * // No existing session + * isQualityChangeScenario(null); + * // Returns: null + */ +export function isQualityChangeScenario( + existingActiveSession: { + id: string; + referenceId: string | null; + stoppedAt: Date | null; + } | null | undefined +): string | null { + // No existing session = not a quality change + if (!existingActiveSession) return null; + + // Session already stopped = not a quality change (this is a resume scenario) + if (existingActiveSession.stoppedAt !== null) return null; + + // Active session exists for same user+content = quality change + // Link to the original session chain + return existingActiveSession.referenceId || existingActiveSession.id; +} + +// ============================================================================ +// Session Grouping (Resume Detection) +// ============================================================================ + +/** + * Determine if a new session should be grouped with a previous session (resume tracking). + * Returns the referenceId to link to, or null if sessions shouldn't be grouped. + * + * Sessions are grouped when: + * - Same user and same media item (ratingKey) + * - Previous session stopped within 24 hours (absolute maximum) + * - Previous session stopped within continued session threshold (default 60s) + * - Previous session wasn't fully watched + * - New session starts at same or later position (resuming, not rewatching) + * + * @param previousSession - Previous session data for the same user/media + * @param newProgressMs - Current playback position of new session + * @param continuedThresholdMs - Optional custom threshold for "continued session" grouping (default: 60s) + * @returns referenceId to link to, or null if not grouping + * + * @example + * // Resuming within 60s (default threshold) + * shouldGroupWithPreviousSession( + * { id: 'sess-1', referenceId: null, progressMs: 1800000, watched: false, stoppedAt: thirtySecondsAgo }, + * 1800000 + * ); // Returns: 'sess-1' + * + * // Continued session with 5 minute threshold + * shouldGroupWithPreviousSession( + * { id: 'sess-1', referenceId: null, progressMs: 1800000, watched: false, stoppedAt: twoMinutesAgo }, + * 1800000, + * 5 * 60 * 1000 // 5 minute threshold + * ); // Returns: 'sess-1' (within threshold) + */ +export function shouldGroupWithPreviousSession( + previousSession: { + referenceId: string | null; + id: string; + progressMs: number | null; + watched: boolean; + stoppedAt: Date | null; + }, + newProgressMs: number, + continuedThresholdMs?: number +): string | null { + // Must have a stoppedAt time + if (!previousSession.stoppedAt) return null; + + // Calculate 24h window internally - absolute maximum for any grouping + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + if (previousSession.stoppedAt < twentyFourHoursAgo) return null; + + // Apply continued session threshold (default: 60 seconds from SESSION_LIMITS) + const thresholdMs = continuedThresholdMs ?? SESSION_LIMITS.CONTINUED_SESSION_THRESHOLD_MS; + const gapMs = Date.now() - previousSession.stoppedAt.getTime(); + if (gapMs > thresholdMs) return null; + + // Must not be fully watched + if (previousSession.watched) return null; + + // New session must be resuming from same or later position + const prevProgress = previousSession.progressMs || 0; + if (newProgressMs >= prevProgress) { + // Link to the first session in the chain + return previousSession.referenceId || previousSession.id; + } + + return null; +} diff --git a/apps/server/src/jobs/poller/types.ts b/apps/server/src/jobs/poller/types.ts new file mode 100644 index 0000000..9a5a679 --- /dev/null +++ b/apps/server/src/jobs/poller/types.ts @@ -0,0 +1,173 @@ +/** + * Poller Type Definitions + * + * Shared interfaces and types for the session polling system. + * Separated from implementation for clean imports and testing. + */ + +import type { Session, SessionState, Rule, RuleParams, ActiveSession } from '@tracearr/shared'; + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/** + * Configuration for the poller job + */ +export interface PollerConfig { + /** Whether polling is enabled */ + enabled: boolean; + /** Polling interval in milliseconds */ + intervalMs: number; +} + +// ============================================================================ +// Server Types +// ============================================================================ + +/** + * Server data with decrypted token for API calls + */ +export interface ServerWithToken { + id: string; + name: string; + type: 'plex' | 'jellyfin'; + url: string; + token: string; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// Session Types +// ============================================================================ + +/** + * Processed session format after mapping from MediaSession + * Contains all fields needed for database storage and display + */ +export interface ProcessedSession { + /** Unique session key from media server */ + sessionKey: string; + /** Plex Session.id - required for termination API (different from sessionKey) */ + plexSessionId?: string; + /** Media item identifier (ratingKey for Plex, itemId for Jellyfin) */ + ratingKey: string; + + // User identification from media server + /** External user ID from Plex/Jellyfin for lookup */ + externalUserId: string; + /** Display name from media server */ + username: string; + /** Avatar URL from media server */ + userThumb: string; + + // Media metadata + /** Media title */ + mediaTitle: string; + /** Media type classification */ + mediaType: 'movie' | 'episode' | 'track'; + /** Show name (for episodes) */ + grandparentTitle: string; + /** Season number (for episodes) */ + seasonNumber: number; + /** Episode number (for episodes) */ + episodeNumber: number; + /** Release year */ + year: number; + /** Poster path */ + thumbPath: string; + + // Connection info + /** Client IP address */ + ipAddress: string; + /** Player/device name */ + playerName: string; + /** Unique device identifier */ + deviceId: string; + /** Product/app name (e.g., "Plex for iOS") */ + product: string; + /** Device type (e.g., "iPhone") */ + device: string; + /** Platform (e.g., "iOS") */ + platform: string; + + // Quality info + /** Quality display string */ + quality: string; + /** Whether stream is transcoded */ + isTranscode: boolean; + /** Bitrate in kbps */ + bitrate: number; + + // Playback state + /** Current playback state */ + state: 'playing' | 'paused'; + /** Total media duration in milliseconds */ + totalDurationMs: number; + /** Current playback position in milliseconds */ + progressMs: number; + + /** + * Jellyfin-specific: When the current pause started (from API). + * More accurate than tracking pause transitions via polling. + */ + lastPausedDate?: Date; +} + +// ============================================================================ +// Pause Tracking Types +// ============================================================================ + +/** + * Result of pause accumulation calculation + */ +export interface PauseAccumulationResult { + /** Timestamp when pause started (null if playing) */ + lastPausedAt: Date | null; + /** Total accumulated pause duration in milliseconds */ + pausedDurationMs: number; +} + +/** + * Result of stop duration calculation + */ +export interface StopDurationResult { + /** Actual watch duration excluding pause time in milliseconds */ + durationMs: number; + /** Final total paused duration in milliseconds */ + finalPausedDurationMs: number; +} + +/** + * Session data needed for pause calculations + */ +export interface SessionPauseData { + startedAt: Date; + lastPausedAt: Date | null; + pausedDurationMs: number; +} + +// ============================================================================ +// Processing Results +// ============================================================================ + +/** + * Result of processing a single server's sessions + */ +export interface ServerProcessingResult { + /** Whether the server was successfully polled (false = connection error) */ + success: boolean; + /** Newly created sessions */ + newSessions: ActiveSession[]; + /** Session keys that stopped playing */ + stoppedSessionKeys: string[]; + /** Sessions that were updated (state change, progress, etc.) */ + updatedSessions: ActiveSession[]; +} + +// ============================================================================ +// Re-exports for convenience +// ============================================================================ + +export type { Session, SessionState, Rule, RuleParams }; diff --git a/apps/server/src/jobs/poller/utils.ts b/apps/server/src/jobs/poller/utils.ts new file mode 100644 index 0000000..c4cac3d --- /dev/null +++ b/apps/server/src/jobs/poller/utils.ts @@ -0,0 +1,157 @@ +/** + * Poller Utility Functions + * + * Pure utility functions for IP detection, client parsing, and formatting. + * These functions have no side effects and are easily testable. + */ + +// ============================================================================ +// IP Address Utilities +// ============================================================================ + +/** + * Check if an IP address is private/local (won't have GeoIP data) + * + * @param ip - IP address to check + * @returns true if the IP is private/local + * + * @example + * isPrivateIP('192.168.1.100'); // true + * isPrivateIP('8.8.8.8'); // false + */ +export function isPrivateIP(ip: string): boolean { + if (!ip) return true; + + // IPv4 private ranges + const privateIPv4 = [ + /^10\./, // 10.0.0.0/8 + /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^127\./, // Loopback + /^169\.254\./, // Link-local + /^0\./, // Current network + ]; + + // IPv6 private ranges + const privateIPv6 = [ + /^::1$/i, // Loopback + /^fe80:/i, // Link-local + /^fc/i, // Unique local + /^fd/i, // Unique local + ]; + + return privateIPv4.some(r => r.test(ip)) || privateIPv6.some(r => r.test(ip)); +} + +// ============================================================================ +// Client Parsing Utilities +// ============================================================================ + +/** + * Parse platform and device info from Jellyfin client string + * + * Jellyfin clients report as "Jellyfin iOS", "Jellyfin Android", "Jellyfin Web", etc. + * This function extracts meaningful platform and device information from these strings. + * + * @param client - Client application name string + * @param deviceType - Optional device type hint + * @returns Parsed platform and device information + * + * @example + * parseJellyfinClient('Jellyfin iOS'); // { platform: 'iOS', device: 'iPhone' } + * parseJellyfinClient('Jellyfin Web'); // { platform: 'Web', device: 'Browser' } + * parseJellyfinClient('Swiftfin'); // { platform: 'tvOS', device: 'Apple TV' } + */ +export function parseJellyfinClient( + client: string, + deviceType?: string +): { platform: string; device: string } { + // If deviceType is provided and meaningful, use it + if (deviceType && deviceType.length > 0 && deviceType !== 'Unknown') { + return { platform: client, device: deviceType }; + } + + const clientLower = client.toLowerCase(); + + // iOS devices + if (clientLower.includes('ios') || clientLower.includes('iphone')) { + return { platform: 'iOS', device: 'iPhone' }; + } + if (clientLower.includes('ipad')) { + return { platform: 'iOS', device: 'iPad' }; + } + + // Android and NVIDIA Shield + if (clientLower.includes('android')) { + if (clientLower.includes('tv') || clientLower.includes('shield')) { + return { platform: 'Android TV', device: 'Android TV' }; + } + return { platform: 'Android', device: 'Android' }; + } + // NVIDIA Shield without "android" in client name + if (clientLower.includes('shield')) { + return { platform: 'Android TV', device: 'Android TV' }; + } + + // Smart TVs + if (clientLower.includes('samsung') || clientLower.includes('tizen')) { + return { platform: 'Tizen', device: 'Samsung TV' }; + } + if (clientLower.includes('webos') || clientLower.includes('lg')) { + return { platform: 'webOS', device: 'LG TV' }; + } + if (clientLower.includes('roku')) { + return { platform: 'Roku', device: 'Roku' }; + } + + // Apple TV + if (clientLower.includes('tvos') || clientLower.includes('apple tv') || clientLower.includes('swiftfin')) { + return { platform: 'tvOS', device: 'Apple TV' }; + } + + // Desktop/Web + if (clientLower.includes('web')) { + return { platform: 'Web', device: 'Browser' }; + } + + // Media players + if (clientLower.includes('kodi')) { + return { platform: 'Kodi', device: 'Kodi' }; + } + if (clientLower.includes('infuse')) { + return { platform: 'Infuse', device: 'Infuse' }; + } + + // Fallback + return { platform: client || 'Unknown', device: deviceType || client || 'Unknown' }; +} + +// ============================================================================ +// Formatting Utilities +// ============================================================================ + +/** + * Format quality string from bitrate and transcoding info + * + * @param transcodeBitrate - Transcoded bitrate in bps (0 if not transcoding) + * @param sourceBitrate - Original source bitrate in bps + * @param isTranscoding - Whether the stream is being transcoded + * @returns Formatted quality string (e.g., "12Mbps", "Transcoding", "Direct") + * + * @example + * formatQualityString(12000000, 20000000, true); // "12Mbps" + * formatQualityString(0, 0, true); // "Transcoding" + * formatQualityString(0, 0, false); // "Direct" + */ +export function formatQualityString( + transcodeBitrate: number, + sourceBitrate: number, + isTranscoding: boolean +): string { + const bitrate = transcodeBitrate || sourceBitrate; + return bitrate > 0 + ? `${Math.round(bitrate / 1000000)}Mbps` + : isTranscoding + ? 'Transcoding' + : 'Direct'; +} diff --git a/apps/server/src/jobs/poller/violations.ts b/apps/server/src/jobs/poller/violations.ts new file mode 100644 index 0000000..613e397 --- /dev/null +++ b/apps/server/src/jobs/poller/violations.ts @@ -0,0 +1,425 @@ +/** + * Violation Handling + * + * Functions for creating violations, calculating trust score penalties, + * and determining rule applicability. + */ + +import { eq, sql, and, isNull, gte } from 'drizzle-orm'; +import type { ExtractTablesWithRelations } from 'drizzle-orm'; +import type { PgTransaction } from 'drizzle-orm/pg-core'; +import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'; +import type { Rule, ViolationSeverity, ViolationWithDetails, RuleType } from '@tracearr/shared'; +import { WS_EVENTS, TIME_MS } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { servers, serverUsers, sessions, violations, users, rules } from '../../db/schema.js'; +import type * as schema from '../../db/schema.js'; +import type { RuleEvaluationResult } from '../../services/rules.js'; +import type { PubSubService } from '../../services/cache.js'; +import { enqueueNotification } from '../notificationQueue.js'; + +// Type for transaction context +type TransactionContext = PgTransaction>; + +// ============================================================================ +// Trust Score Calculation +// ============================================================================ + +/** + * Calculate trust score penalty based on violation severity. + * + * @param severity - Violation severity level + * @returns Trust score penalty (negative value to subtract) + * + * @example + * getTrustScorePenalty('high'); // 20 + * getTrustScorePenalty('warning'); // 10 + * getTrustScorePenalty('low'); // 5 + */ +export function getTrustScorePenalty(severity: ViolationSeverity): number { + return severity === 'high' ? 20 : severity === 'warning' ? 10 : 5; +} + +// ============================================================================ +// Violation Deduplication +// ============================================================================ + +// Deduplication window - violations within this time window with overlapping sessions are considered duplicates +const VIOLATION_DEDUP_WINDOW_MS = 5 * TIME_MS.MINUTE; + +/** + * Check if a duplicate violation already exists for the same user/rule type with overlapping sessions. + * + * This prevents creating multiple violations when: + * - Multiple sessions start simultaneously and each sees the others as active + * - The same violation event is detected by both SSE and poller + * + * A violation is considered a duplicate if: + * - Same serverUserId + * - Same rule type (not just ruleId - any rule of the same type) + * - Created within the dedup window + * - Not yet acknowledged + * - Any overlap in relatedSessionIds OR the triggering session is in the other's related sessions + * + * @param serverUserId - Server user who violated the rule + * @param ruleType - Type of rule (concurrent_streams, simultaneous_locations, etc.) + * @param triggeringSessionId - The session that triggered this violation + * @param relatedSessionIds - Session IDs involved in this violation + * @returns true if a duplicate violation exists + */ +export async function isDuplicateViolation( + serverUserId: string, + ruleType: RuleType, + triggeringSessionId: string, + relatedSessionIds: string[] +): Promise { + // Only deduplicate for rules that involve multiple sessions + if (!['concurrent_streams', 'simultaneous_locations'].includes(ruleType)) { + return false; + } + + const windowStart = new Date(Date.now() - VIOLATION_DEDUP_WINDOW_MS); + + // Find recent unacknowledged violations for same user and rule type + const recentViolations = await db + .select({ + id: violations.id, + sessionId: violations.sessionId, + data: violations.data, + }) + .from(violations) + .innerJoin(rules, eq(violations.ruleId, rules.id)) + .where( + and( + eq(violations.serverUserId, serverUserId), + eq(rules.type, ruleType), + isNull(violations.acknowledgedAt), + gte(violations.createdAt, windowStart) + ) + ); + + if (recentViolations.length === 0) { + return false; + } + + // Check for overlap with any recent violation + for (const existing of recentViolations) { + const existingData = existing.data as Record | null; + const existingRelatedIds = (existingData?.relatedSessionIds as string[]) || []; + + // Case 1: This triggering session is already covered as a related session in an existing violation + if (existingRelatedIds.includes(triggeringSessionId)) { + console.log( + `[Violations] Skipping duplicate: triggering session ${triggeringSessionId} is related to existing violation ${existing.id}` + ); + return true; + } + + // Case 2: The existing violation's triggering session is in our related sessions + if (existing.sessionId && relatedSessionIds.includes(existing.sessionId)) { + console.log( + `[Violations] Skipping duplicate: existing violation ${existing.id} triggered by session in our related sessions` + ); + return true; + } + + // Case 3: Any overlap in related session IDs + const hasOverlap = relatedSessionIds.some((id) => existingRelatedIds.includes(id)); + if (hasOverlap) { + console.log( + `[Violations] Skipping duplicate: overlapping related sessions with existing violation ${existing.id}` + ); + return true; + } + } + + return false; +} + +// ============================================================================ +// Rule Applicability +// ============================================================================ + +/** + * Check if a rule applies to a specific server user. + * + * Global rules (serverUserId=null) apply to all server users. + * User-specific rules only apply to that server user. + * + * @param rule - Rule to check + * @param serverUserId - Server user ID to check against + * @returns true if the rule applies to this server user + * + * @example + * doesRuleApplyToUser({ serverUserId: null }, 'su-123'); // true (global rule) + * doesRuleApplyToUser({ serverUserId: 'su-123' }, 'su-123'); // true (user-specific) + * doesRuleApplyToUser({ serverUserId: 'su-456' }, 'su-123'); // false (different user) + */ +export function doesRuleApplyToUser( + rule: { serverUserId: string | null }, + serverUserId: string +): boolean { + return rule.serverUserId === null || rule.serverUserId === serverUserId; +} + +// ============================================================================ +// Violation Creation +// ============================================================================ + +/** + * Create a violation from rule evaluation result. + * Uses a transaction to ensure violation insert and trust score update are atomic. + * + * @deprecated Use `createViolationInTransaction()` + `broadcastViolations()` instead + * for proper atomic behavior when creating sessions and violations together. + * This function creates its own transaction, which cannot be combined with + * session creation. Only use this for standalone violation creation outside + * the poller flow. + * + * @param ruleId - ID of the rule that was violated + * @param serverUserId - ID of the server user who violated the rule + * @param sessionId - ID of the session where violation occurred + * @param result - Rule evaluation result with severity and data + * @param rule - Full rule object for broadcast details + * @param pubSubService - Optional pub/sub service for WebSocket broadcast + * + * @example + * // Preferred pattern (in poller): + * const violationResults = await db.transaction(async (tx) => { + * const session = await tx.insert(sessions).values(data).returning(); + * return await createViolationInTransaction(tx, ruleId, serverUserId, session.id, result, rule); + * }); + * await broadcastViolations(violationResults, sessionId, pubSubService); + * + * // Legacy pattern (standalone, avoid in new code): + * await createViolation(ruleId, serverUserId, sessionId, result, rule, pubSubService); + */ +export async function createViolation( + ruleId: string, + serverUserId: string, + sessionId: string, + result: RuleEvaluationResult, + rule: Rule, + pubSubService: PubSubService | null +): Promise { + // Calculate trust penalty based on severity + const trustPenalty = getTrustScorePenalty(result.severity); + + // Use transaction to ensure violation creation and trust score update are atomic + const created = await db.transaction(async (tx) => { + const [violation] = await tx + .insert(violations) + .values({ + ruleId, + serverUserId, + sessionId, + severity: result.severity, + data: result.data, + }) + .returning(); + + // Decrease server user trust score based on severity (atomic within transaction) + await tx + .update(serverUsers) + .set({ + trustScore: sql`GREATEST(0, ${serverUsers.trustScore} - ${trustPenalty})`, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, serverUserId)); + + return violation; + }); + + // Get server user and server details for the violation broadcast (outside transaction - read only) + const [details] = await db + .select({ + userId: serverUsers.id, + username: serverUsers.username, + thumbUrl: serverUsers.thumbUrl, + identityName: users.name, + serverId: servers.id, + serverName: servers.name, + serverType: servers.type, + }) + .from(serverUsers) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .innerJoin(sessions, eq(sessions.id, sessionId)) + .innerJoin(servers, eq(servers.id, sessions.serverId)) + .where(eq(serverUsers.id, serverUserId)) + .limit(1); + + // Publish violation event for WebSocket broadcast + if (pubSubService && created && details) { + const violationWithDetails: ViolationWithDetails = { + id: created.id, + ruleId: created.ruleId, + serverUserId: created.serverUserId, + sessionId: created.sessionId, + severity: created.severity, + data: created.data, + acknowledgedAt: created.acknowledgedAt, + createdAt: created.createdAt, + user: { + id: details.userId, + username: details.username, + thumbUrl: details.thumbUrl, + serverId: details.serverId, + identityName: details.identityName, + }, + rule: { + id: rule.id, + name: rule.name, + type: rule.type, + }, + server: { + id: details.serverId, + name: details.serverName, + type: details.serverType, + }, + }; + + await pubSubService.publish(WS_EVENTS.VIOLATION_NEW, violationWithDetails); + console.log(`[Poller] Violation broadcast: ${rule.name} for user ${details.username}`); + + // Enqueue notification for async dispatch (Discord, webhooks, push) + await enqueueNotification({ type: 'violation', payload: violationWithDetails }); + } +} + +// ============================================================================ +// Transaction-Aware Violation Creation +// ============================================================================ + +/** + * Result of creating a violation within a transaction. + * Contains data needed for post-transaction broadcasting. + */ +export interface ViolationInsertResult { + violation: typeof violations.$inferSelect; + rule: Rule; + trustPenalty: number; +} + +/** + * Create a violation within an existing transaction context. + * Use this when session insert + violation creation must be atomic. + * + * This function: + * 1. Inserts the violation record + * 2. Updates the server user's trust score + * Both within the provided transaction. + * + * Broadcasting/notification must be done AFTER the transaction commits. + * + * @param tx - Transaction context + * @param ruleId - ID of the rule that was violated + * @param serverUserId - ID of the server user who violated the rule + * @param sessionId - ID of the session where violation occurred + * @param result - Rule evaluation result with severity and data + * @param rule - Full rule object for broadcast details + * @returns Violation insert result for post-transaction broadcasting + */ +export async function createViolationInTransaction( + tx: TransactionContext, + ruleId: string, + serverUserId: string, + sessionId: string, + result: RuleEvaluationResult, + rule: Rule +): Promise { + const trustPenalty = getTrustScorePenalty(result.severity); + + const [violation] = await tx + .insert(violations) + .values({ + ruleId, + serverUserId, + sessionId, + severity: result.severity, + data: result.data, + }) + .returning(); + + // Decrease server user trust score based on severity + await tx + .update(serverUsers) + .set({ + trustScore: sql`GREATEST(0, ${serverUsers.trustScore} - ${trustPenalty})`, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, serverUserId)); + + return { violation: violation!, rule, trustPenalty }; +} + +/** + * Broadcast violation events after transaction has committed. + * Call this AFTER the transaction to ensure data is persisted before broadcasting. + * + * @param violationResults - Array of violation insert results + * @param sessionId - Session ID for fetching server details + * @param pubSubService - PubSub service for WebSocket broadcast + */ +export async function broadcastViolations( + violationResults: ViolationInsertResult[], + sessionId: string, + pubSubService: PubSubService | null +): Promise { + if (!pubSubService || violationResults.length === 0) return; + + // Get server user and server details for the violation broadcast (single query for all) + const [details] = await db + .select({ + userId: serverUsers.id, + username: serverUsers.username, + thumbUrl: serverUsers.thumbUrl, + identityName: users.name, + serverId: servers.id, + serverName: servers.name, + serverType: servers.type, + }) + .from(sessions) + .innerJoin(serverUsers, eq(serverUsers.id, sessions.serverUserId)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .innerJoin(servers, eq(servers.id, sessions.serverId)) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (!details) return; + + for (const { violation, rule } of violationResults) { + const violationWithDetails: ViolationWithDetails = { + id: violation.id, + ruleId: violation.ruleId, + serverUserId: violation.serverUserId, + sessionId: violation.sessionId, + severity: violation.severity, + data: violation.data, + acknowledgedAt: violation.acknowledgedAt, + createdAt: violation.createdAt, + user: { + id: details.userId, + username: details.username, + thumbUrl: details.thumbUrl, + serverId: details.serverId, + identityName: details.identityName, + }, + rule: { + id: rule.id, + name: rule.name, + type: rule.type, + }, + server: { + id: details.serverId, + name: details.serverName, + type: details.serverType, + }, + }; + + await pubSubService.publish(WS_EVENTS.VIOLATION_NEW, violationWithDetails); + console.log(`[Poller] Violation broadcast: ${rule.name} for user ${details.username}`); + + // Enqueue notification for async dispatch (Discord, webhooks, push) + await enqueueNotification({ type: 'violation', payload: violationWithDetails }); + } +} diff --git a/apps/server/src/jobs/sseProcessor.ts b/apps/server/src/jobs/sseProcessor.ts new file mode 100644 index 0000000..a4f2263 --- /dev/null +++ b/apps/server/src/jobs/sseProcessor.ts @@ -0,0 +1,644 @@ +/** + * SSE Event Processor + * + * Handles incoming SSE events and updates sessions accordingly. + * This bridges the real-time SSE events to the existing session processing logic. + * + * Flow: + * 1. SSE event received (playing/paused/stopped/progress) + * 2. Fetch full session details from Plex API (SSE only gives minimal info) + * 3. Process session update using existing poller logic + * 4. Broadcast updates via WebSocket + */ + +import { eq, and, isNull } from 'drizzle-orm'; +import type { PlexPlaySessionNotification, ActiveSession } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { servers, sessions, serverUsers, users } from '../db/schema.js'; +import { createMediaServerClient } from '../services/mediaServer/index.js'; +import { sseManager } from '../services/sseManager.js'; +import type { CacheService, PubSubService } from '../services/cache.js'; +import { geoipService } from '../services/geoip.js'; +import { ruleEngine } from '../services/rules.js'; +import { mapMediaSession } from './poller/sessionMapper.js'; +import { calculatePauseAccumulation, calculateStopDuration, checkWatchCompletion } from './poller/stateTracker.js'; +import { getActiveRules, batchGetRecentUserSessions } from './poller/database.js'; +import { createViolation, isDuplicateViolation } from './poller/violations.js'; +import { enqueueNotification } from './notificationQueue.js'; +import { triggerReconciliationPoll } from './poller/index.js'; + +let cacheService: CacheService | null = null; +let pubSubService: PubSubService | null = null; + +// Store wrapped handlers so we can properly remove them +interface SessionEvent { serverId: string; notification: PlexPlaySessionNotification } +const wrappedHandlers = { + playing: (e: SessionEvent) => void handlePlaying(e), + paused: (e: SessionEvent) => void handlePaused(e), + stopped: (e: SessionEvent) => void handleStopped(e), + progress: (e: SessionEvent) => void handleProgress(e), + reconciliation: () => void handleReconciliation(), +}; + +/** + * Initialize the SSE processor with cache services + */ +export function initializeSSEProcessor(cache: CacheService, pubSub: PubSubService): void { + cacheService = cache; + pubSubService = pubSub; +} + +/** + * Start the SSE processor + * Subscribes to SSE manager events and processes them + * Note: sseManager.start() is called separately in index.ts after server is listening + */ +export function startSSEProcessor(): void { + if (!cacheService || !pubSubService) { + throw new Error('SSE processor not initialized'); + } + + console.log('[SSEProcessor] Starting'); + + // Subscribe to SSE events + sseManager.on('plex:session:playing', wrappedHandlers.playing); + sseManager.on('plex:session:paused', wrappedHandlers.paused); + sseManager.on('plex:session:stopped', wrappedHandlers.stopped); + sseManager.on('plex:session:progress', wrappedHandlers.progress); + sseManager.on('reconciliation:needed', wrappedHandlers.reconciliation); +} + +/** + * Stop the SSE processor + * Note: sseManager.stop() is called separately in index.ts during cleanup + */ +export function stopSSEProcessor(): void { + console.log('[SSEProcessor] Stopping'); + + sseManager.off('plex:session:playing', wrappedHandlers.playing); + sseManager.off('plex:session:paused', wrappedHandlers.paused); + sseManager.off('plex:session:stopped', wrappedHandlers.stopped); + sseManager.off('plex:session:progress', wrappedHandlers.progress); + sseManager.off('reconciliation:needed', wrappedHandlers.reconciliation); +} + +/** + * Handle playing event (new session or resume) + */ +async function handlePlaying(event: { + serverId: string; + notification: PlexPlaySessionNotification; +}): Promise { + const { serverId, notification } = event; + + try { + const session = await fetchFullSession(serverId, notification.sessionKey); + if (!session) { + return; + } + + const existingRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + eq(sessions.sessionKey, notification.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + if (existingRows[0]) { + await updateExistingSession(existingRows[0], session, 'playing'); + } else { + await createNewSession(serverId, session); + } + } catch (error) { + console.error('[SSEProcessor] Error handling playing event:', error); + } +} + +/** + * Handle paused event + */ +async function handlePaused(event: { + serverId: string; + notification: PlexPlaySessionNotification; +}): Promise { + const { serverId, notification } = event; + + try { + const existingRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + eq(sessions.sessionKey, notification.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + if (!existingRows[0]) { + return; + } + + const session = await fetchFullSession(serverId, notification.sessionKey); + if (session) { + await updateExistingSession(existingRows[0], session, 'paused'); + } + } catch (error) { + console.error('[SSEProcessor] Error handling paused event:', error); + } +} + +/** + * Handle stopped event + */ +async function handleStopped(event: { + serverId: string; + notification: PlexPlaySessionNotification; +}): Promise { + const { serverId, notification } = event; + + try { + // Query without limit to handle any duplicate sessions that may exist + const existingRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + eq(sessions.sessionKey, notification.sessionKey), + isNull(sessions.stoppedAt) + ) + ); + + if (existingRows.length === 0) { + return; + } + + // Stop all matching sessions (handles potential duplicates) + for (const session of existingRows) { + await stopSession(session); + } + } catch (error) { + console.error('[SSEProcessor] Error handling stopped event:', error); + } +} + +/** + * Handle progress event (periodic position updates) + */ +async function handleProgress(event: { + serverId: string; + notification: PlexPlaySessionNotification; +}): Promise { + const { serverId, notification } = event; + + try { + const existingRows = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + eq(sessions.sessionKey, notification.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + if (!existingRows[0]) { + return; + } + + // Update progress in database + const watched = existingRows[0].watched || checkWatchCompletion( + notification.viewOffset, + existingRows[0].totalDurationMs + ); + + await db + .update(sessions) + .set({ + progressMs: notification.viewOffset, + watched, + }) + .where(eq(sessions.id, existingRows[0].id)); + + // Update cache + if (cacheService) { + const cached = await cacheService.getSessionById(existingRows[0].id); + if (cached) { + cached.progressMs = notification.viewOffset; + cached.watched = watched; + await cacheService.setSessionById(existingRows[0].id, cached); + } + } + + // Broadcast update (but don't spam - progress events are frequent) + // Only broadcast if there's a significant change (e.g., watched status changed) + if (watched && !existingRows[0].watched && pubSubService) { + const cached = await cacheService?.getSessionById(existingRows[0].id); + if (cached) { + await pubSubService.publish('session:updated', cached); + } + } + } catch (error) { + console.error('[SSEProcessor] Error handling progress event:', error); + } +} + +/** + * Handle reconciliation request + * Triggers a light poll for SSE-connected servers to catch any missed events + */ +async function handleReconciliation(): Promise { + console.log('[SSEProcessor] Triggering reconciliation poll'); + await triggerReconciliationPoll(); +} + +/** + * Fetch full session details from Plex server + */ +async function fetchFullSession( + serverId: string, + sessionKey: string +): Promise | null> { + try { + const serverRows = await db + .select() + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); + + const server = serverRows[0]; + if (!server) { + return null; + } + + const client = createMediaServerClient({ + type: server.type as 'plex', + url: server.url, + token: server.token, + }); + + const allSessions = await client.getSessions(); + const targetSession = allSessions.find(s => s.sessionKey === sessionKey); + + if (!targetSession) { + return null; + } + + return mapMediaSession(targetSession, server.type as 'plex'); + } catch (error) { + console.error(`[SSEProcessor] Error fetching session ${sessionKey}:`, error); + return null; + } +} + +/** + * Create a new session from SSE event + */ +async function createNewSession( + serverId: string, + processed: ReturnType +): Promise { + // Get server info + const serverRows = await db + .select() + .from(servers) + .where(eq(servers.id, serverId)) + .limit(1); + + const server = serverRows[0]; + if (!server) { + return; + } + + // Get or create server user (with identity name from users table) + const serverUserRows = await db + .select({ + id: serverUsers.id, + username: serverUsers.username, + thumbUrl: serverUsers.thumbUrl, + identityName: users.name, + }) + .from(serverUsers) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .where( + and( + eq(serverUsers.serverId, serverId), + eq(serverUsers.externalId, processed.externalUserId) + ) + ) + .limit(1); + + const serverUserId = serverUserRows[0]?.id; + + if (!serverUserId) { + // This shouldn't happen often since users are synced, but handle it + console.warn(`[SSEProcessor] Server user not found for ${processed.externalUserId}, skipping`); + return; + } + + // GeoIP lookup + const geo = geoipService.lookup(processed.ipAddress); + + // Check if an active session already exists (prevents race condition with poller) + // This can happen when SSE and poller both try to create a session simultaneously + const existingActiveSession = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + eq(sessions.sessionKey, processed.sessionKey), + isNull(sessions.stoppedAt) + ) + ) + .limit(1); + + if (existingActiveSession.length > 0) { + // Session already exists (likely created by poller), skip insert + // The existing session will be updated by subsequent SSE events + console.log(`[SSEProcessor] Active session already exists for ${processed.sessionKey}, skipping create`); + return; + } + + // Insert new session + const insertedRows = await db + .insert(sessions) + .values({ + serverId, + serverUserId, + sessionKey: processed.sessionKey, + ratingKey: processed.ratingKey || null, + state: processed.state, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + startedAt: new Date(), + lastSeenAt: new Date(), // Track when we first saw this session + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + lastPausedAt: processed.state === 'paused' ? new Date() : null, + pausedDurationMs: 0, + watched: false, + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + }) + .returning(); + + const inserted = insertedRows[0]; + if (!inserted) { + return; + } + + // Get server user details + const serverUserFromDb = serverUserRows[0]; + const userDetail = serverUserFromDb + ? { + id: serverUserFromDb.id, + username: serverUserFromDb.username, + thumbUrl: serverUserFromDb.thumbUrl, + identityName: serverUserFromDb.identityName, + } + : { id: serverUserId, username: 'Unknown', thumbUrl: null, identityName: null }; + + // Build active session + const activeSession: ActiveSession = { + id: inserted.id, + serverId, + serverUserId, + sessionKey: processed.sessionKey, + state: processed.state, + mediaType: processed.mediaType, + mediaTitle: processed.mediaTitle, + grandparentTitle: processed.grandparentTitle || null, + seasonNumber: processed.seasonNumber || null, + episodeNumber: processed.episodeNumber || null, + year: processed.year || null, + thumbPath: processed.thumbPath || null, + ratingKey: processed.ratingKey || null, + externalSessionId: null, + startedAt: inserted.startedAt, + stoppedAt: null, + durationMs: null, + totalDurationMs: processed.totalDurationMs || null, + progressMs: processed.progressMs || null, + lastPausedAt: inserted.lastPausedAt, + pausedDurationMs: 0, + referenceId: null, + watched: false, + ipAddress: processed.ipAddress, + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: processed.playerName, + deviceId: processed.deviceId || null, + product: processed.product || null, + device: processed.device || null, + platform: processed.platform, + quality: processed.quality, + isTranscode: processed.isTranscode, + bitrate: processed.bitrate, + user: userDetail, + server: { id: server.id, name: server.name, type: server.type as 'plex' }, + }; + + // Update cache atomically + if (cacheService) { + // Add to active sessions SET + store session data (atomic) + await cacheService.addActiveSession(activeSession); + await cacheService.addUserSession(serverUserId, inserted.id); + } + + // Broadcast new session + if (pubSubService) { + await pubSubService.publish('session:started', activeSession); + await enqueueNotification({ type: 'session_started', payload: activeSession }); + } + + // Evaluate rules + const activeRules = await getActiveRules(); + const recentSessions = await batchGetRecentUserSessions([serverUserId]); + const ruleResults = await ruleEngine.evaluateSession(inserted, activeRules, recentSessions.get(serverUserId) ?? []); + + for (const result of ruleResults) { + const matchingRule = activeRules.find( + (r) => (r.serverUserId === null || r.serverUserId === serverUserId) && result.violated + ); + if (matchingRule) { + // Check for duplicate violations before creating + // This prevents multiple violations when sessions start simultaneously + const relatedSessionIds = (result.data?.relatedSessionIds as string[]) || []; + const isDuplicate = await isDuplicateViolation( + serverUserId, + matchingRule.type, + inserted.id, + relatedSessionIds + ); + + if (isDuplicate) { + continue; // Skip creating duplicate violation + } + + // TODO: Refactor to use createViolationInTransaction pattern for atomicity + // Session is already inserted before rule evaluation, so using standalone function for now + // eslint-disable-next-line @typescript-eslint/no-deprecated + await createViolation(matchingRule.id, serverUserId, inserted.id, result, matchingRule, pubSubService); + } + } + + console.log(`[SSEProcessor] Created session ${inserted.id} for ${processed.mediaTitle}`); +} + +/** + * Update an existing session + */ +async function updateExistingSession( + existingSession: typeof sessions.$inferSelect, + processed: ReturnType, + newState: 'playing' | 'paused' +): Promise { + const now = new Date(); + const previousState = existingSession.state; + + // Calculate pause accumulation + const pauseResult = calculatePauseAccumulation( + previousState, + newState, + { lastPausedAt: existingSession.lastPausedAt, pausedDurationMs: existingSession.pausedDurationMs || 0 }, + now + ); + + // Check watch completion + const watched = existingSession.watched || checkWatchCompletion( + processed.progressMs, + processed.totalDurationMs + ); + + // Update session in database + await db + .update(sessions) + .set({ + state: newState, + quality: processed.quality, + bitrate: processed.bitrate, + progressMs: processed.progressMs || null, + lastPausedAt: pauseResult.lastPausedAt, + pausedDurationMs: pauseResult.pausedDurationMs, + watched, + }) + .where(eq(sessions.id, existingSession.id)); + + // Update cache and broadcast + if (cacheService) { + let cached = await cacheService.getSessionById(existingSession.id); + + // If cache miss, try to get from active sessions SET + if (!cached) { + const allActive = await cacheService.getAllActiveSessions(); + cached = allActive.find((s) => s.id === existingSession.id) || null; + } + + if (cached) { + // Update cached session with new state + cached.state = newState; + cached.quality = processed.quality; + cached.bitrate = processed.bitrate; + cached.progressMs = processed.progressMs || null; + cached.lastPausedAt = pauseResult.lastPausedAt; + cached.pausedDurationMs = pauseResult.pausedDurationMs; + cached.watched = watched; + + // Atomic update: just update this session's data (ID already in SET) + await cacheService.updateActiveSession(cached); + + // Broadcast the update + if (pubSubService) { + await pubSubService.publish('session:updated', cached); + } + } + } +} + +/** + * Stop a session + */ +async function stopSession(existingSession: typeof sessions.$inferSelect): Promise { + const stoppedAt = new Date(); + + // Calculate final duration + const { durationMs, finalPausedDurationMs } = calculateStopDuration( + { + startedAt: existingSession.startedAt, + lastPausedAt: existingSession.lastPausedAt, + pausedDurationMs: existingSession.pausedDurationMs || 0, + }, + stoppedAt + ); + + // Check watch completion + const watched = existingSession.watched || checkWatchCompletion( + existingSession.progressMs, + existingSession.totalDurationMs + ); + + // Update session + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt, + durationMs, + pausedDurationMs: finalPausedDurationMs, + lastPausedAt: null, + watched, + }) + .where(eq(sessions.id, existingSession.id)); + + // Get session details for notification BEFORE removing from cache + const cachedSession = await cacheService?.getSessionById(existingSession.id); + + // Update cache atomically (no more race condition) + if (cacheService) { + // Atomic remove from active sessions SET + delete session data + await cacheService.removeActiveSession(existingSession.id); + await cacheService.removeUserSession(existingSession.serverUserId, existingSession.id); + } + + // Broadcast stopped + if (pubSubService) { + await pubSubService.publish('session:stopped', existingSession.id); + + // Use cached session data for notification + if (cachedSession) { + await enqueueNotification({ type: 'session_stopped', payload: cachedSession }); + } + } + + console.log(`[SSEProcessor] Stopped session ${existingSession.id}`); +} diff --git a/apps/server/src/plugins/auth.ts b/apps/server/src/plugins/auth.ts new file mode 100644 index 0000000..b90ffdb --- /dev/null +++ b/apps/server/src/plugins/auth.ts @@ -0,0 +1,82 @@ +/** + * Authentication plugin for Fastify + */ + +import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; +import fp from 'fastify-plugin'; +import jwt from '@fastify/jwt'; +import type { AuthUser } from '@tracearr/shared'; + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: AuthUser; + user: AuthUser; + } +} + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + requireOwner: (request: FastifyRequest, reply: FastifyReply) => Promise; + requireMobile: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} + +const authPlugin: FastifyPluginAsync = async (app) => { + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error('JWT_SECRET environment variable is required'); + } + + await app.register(jwt, { + secret, + sign: { + algorithm: 'HS256', + }, + cookie: { + cookieName: 'token', + signed: false, + }, + }); + + // Authenticate decorator - verifies JWT + app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Require owner role decorator + app.decorate('requireOwner', async function (request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + + if (request.user.role !== 'owner') { + reply.forbidden('Owner access required'); + } + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Require mobile token decorator - validates token was issued for mobile app + app.decorate('requireMobile', async function (request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify(); + + if (!request.user.mobile) { + reply.forbidden('Mobile access token required'); + } + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); +}; + +export default fp(authPlugin, { + name: 'auth', + dependencies: ['@fastify/cookie'], +}); diff --git a/apps/server/src/plugins/redis.ts b/apps/server/src/plugins/redis.ts new file mode 100644 index 0000000..902de53 --- /dev/null +++ b/apps/server/src/plugins/redis.ts @@ -0,0 +1,50 @@ +/** + * Redis client plugin for Fastify + */ + +import type { FastifyPluginAsync } from 'fastify'; +import fp from 'fastify-plugin'; +import { Redis } from 'ioredis'; + +declare module 'fastify' { + interface FastifyInstance { + redis: Redis; + } +} + +const redisPlugin: FastifyPluginAsync = async (app) => { + const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'; + + const redis = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + retryStrategy(times: number) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + reconnectOnError(err: Error) { + const targetError = 'READONLY'; + if (err.message.includes(targetError)) { + return true; + } + return false; + }, + }); + + redis.on('connect', () => { + app.log.info('Redis connected'); + }); + + redis.on('error', (err: Error) => { + app.log.error({ err }, 'Redis error'); + }); + + app.decorate('redis', redis); + + app.addHook('onClose', async () => { + await redis.quit(); + }); +}; + +export default fp(redisPlugin, { + name: 'redis', +}); diff --git a/apps/server/src/plugins/validation.ts b/apps/server/src/plugins/validation.ts new file mode 100644 index 0000000..699a6df --- /dev/null +++ b/apps/server/src/plugins/validation.ts @@ -0,0 +1,114 @@ +/** + * Zod validation plugin for Fastify + * Provides schema validation for request body, query, and params + */ + +import type { + FastifyPluginAsync, + FastifyRequest, + FastifyReply, + preHandlerHookHandler, +} from 'fastify'; +import fp from 'fastify-plugin'; +import { type z, ZodError } from 'zod'; +import { ValidationError } from '../utils/errors.js'; + +// Validation schema options +export interface ValidationSchemas { + body?: z.ZodType; + query?: z.ZodType; + params?: z.ZodType; +} + +declare module 'fastify' { + interface FastifyInstance { + validateRequest: (schemas: ValidationSchemas) => preHandlerHookHandler; + } +} + +/** + * Parse Zod error into field-level errors + */ +function parseZodError(error: ZodError): Array<{ field: string; message: string }> { + return error.issues.map((issue) => ({ + field: issue.path.join('.') || 'unknown', + message: issue.message, + })); +} + +const validationPlugin: FastifyPluginAsync = async (app) => { + /** + * Create a preHandler that validates request against Zod schemas + */ + app.decorate('validateRequest', function (schemas: ValidationSchemas): preHandlerHookHandler { + return function ( + request: FastifyRequest, + _reply: FastifyReply + ): void { + const errors: Array<{ field: string; message: string }> = []; + + // Validate body + if (schemas.body) { + try { + const result = schemas.body.parse(request.body); + // Replace body with parsed/transformed result + request.body = result; + } catch (err) { + if (err instanceof ZodError) { + errors.push( + ...parseZodError(err).map((e) => ({ + field: `body.${e.field}`, + message: e.message, + })) + ); + } + } + } + + // Validate query + if (schemas.query) { + try { + const result = schemas.query.parse(request.query); + // Replace query with parsed/transformed result + request.query = result; + } catch (err) { + if (err instanceof ZodError) { + errors.push( + ...parseZodError(err).map((e) => ({ + field: `query.${e.field}`, + message: e.message, + })) + ); + } + } + } + + // Validate params + if (schemas.params) { + try { + const result = schemas.params.parse(request.params); + // Replace params with parsed/transformed result + request.params = result; + } catch (err) { + if (err instanceof ZodError) { + errors.push( + ...parseZodError(err).map((e) => ({ + field: `params.${e.field}`, + message: e.message, + })) + ); + } + } + } + + // If any validation errors, throw ValidationError + if (errors.length > 0) { + throw new ValidationError('Validation failed', errors); + } + }; + }); +}; + +export default fp(validationPlugin, { + name: 'validation', +}); diff --git a/apps/server/src/routes/__tests__/channelRouting.test.ts b/apps/server/src/routes/__tests__/channelRouting.test.ts new file mode 100644 index 0000000..d83bfe4 --- /dev/null +++ b/apps/server/src/routes/__tests__/channelRouting.test.ts @@ -0,0 +1,598 @@ +/** + * Channel Routing routes unit tests + * + * Tests the API endpoints for notification channel routing: + * - GET /routing - Get all routing configuration + * - PATCH /routing/:eventType - Update routing for specific event + * + * Also tests internal helper functions: + * - getChannelRouting() - Get routing for a specific event type + * - getAllChannelRouting() - Get all routing configuration + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock the database module +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, +})); + +// Import mocked db and routes +import { db } from '../../db/client.js'; +import { + channelRoutingRoutes, + getChannelRouting, + getAllChannelRouting, +} from '../channelRouting.js'; + +/** + * Create mock routing row + */ +function createMockRouting( + eventType: string, + overrides?: Partial<{ + id: string; + discordEnabled: boolean; + webhookEnabled: boolean; + pushEnabled: boolean; + createdAt: Date; + updatedAt: Date; + }> +) { + return { + id: overrides?.id ?? randomUUID(), + eventType, + discordEnabled: overrides?.discordEnabled ?? true, + webhookEnabled: overrides?.webhookEnabled ?? true, + pushEnabled: overrides?.pushEnabled ?? true, + createdAt: overrides?.createdAt ?? new Date(), + updatedAt: overrides?.updatedAt ?? new Date(), + }; +} + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + // Register routes (using settings prefix as per route registration) + await app.register(channelRoutingRoutes, { prefix: '/settings/notifications' }); + + return app; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +/** + * Mock db.select() to return array of items with orderBy + */ +function mockDbSelectAll(items: unknown[]) { + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(items), + }), + } as never); +} + +/** + * Mock db.select() to return single item with where + limit + * Note: Currently unused but kept for future tests + */ +function _mockDbSelectOne(item: unknown) { + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(item ? [item] : []), + }), + }), + } as never); +} + +/** + * Mock db.insert() to return inserted items + */ +function mockDbInsert(items: unknown[]) { + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue(items), + }), + } as never); +} + +/** + * Mock db.update() to return nothing + */ +function mockDbUpdate() { + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); +} + +describe('Channel Routing Routes', () => { + let app: FastifyInstance; + const ownerUser = createOwnerUser(); + const viewerUser = createViewerUser(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /settings/notifications/routing', () => { + it('returns all routing configuration for owner', async () => { + app = await buildTestApp(ownerUser); + + const mockRoutings = [ + createMockRouting('violation_detected'), + createMockRouting('stream_started', { discordEnabled: false }), + createMockRouting('stream_stopped', { pushEnabled: false }), + ]; + + mockDbSelectAll(mockRoutings); + + const response = await app.inject({ + method: 'GET', + url: '/settings/notifications/routing', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toHaveLength(3); + expect(body[0]).toHaveProperty('eventType', 'violation_detected'); + expect(body[0]).toHaveProperty('discordEnabled', true); + expect(body[1]).toHaveProperty('eventType', 'stream_started'); + expect(body[1]).toHaveProperty('discordEnabled', false); + }); + + it('creates default routing if no rows exist', async () => { + app = await buildTestApp(ownerUser); + + // First call returns empty, meaning no routing exists + mockDbSelectAll([]); + + // Mock insert to return defaults + const defaultRoutings = [ + createMockRouting('violation_detected'), + createMockRouting('stream_started', { + discordEnabled: false, + webhookEnabled: false, + pushEnabled: false, + }), + ]; + mockDbInsert(defaultRoutings); + + const response = await app.inject({ + method: 'GET', + url: '/settings/notifications/routing', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toHaveLength(2); + expect(db.insert).toHaveBeenCalled(); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/settings/notifications/routing', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can view notification routing'); + }); + }); + + describe('PATCH /settings/notifications/routing/:eventType', () => { + it('updates existing routing for owner', async () => { + app = await buildTestApp(ownerUser); + + const existingRouting = createMockRouting('violation_detected'); + + // First select finds existing + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([existingRouting]), + }), + }), + } as never); + + // Mock update + mockDbUpdate(); + + // Second select returns updated + const updatedRouting = { + ...existingRouting, + discordEnabled: false, + updatedAt: new Date(), + }; + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([updatedRouting]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/violation_detected', + payload: { discordEnabled: false }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.eventType).toBe('violation_detected'); + expect(body.discordEnabled).toBe(false); + expect(db.update).toHaveBeenCalled(); + }); + + it('creates new routing if none exists', async () => { + app = await buildTestApp(ownerUser); + + const newRouting = createMockRouting('server_down', { + discordEnabled: true, + webhookEnabled: true, + pushEnabled: false, + }); + + // Track select calls - first returns empty, second returns created routing + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + // First call - no existing routing + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } else { + // Second call - return newly created routing + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([newRouting]), + }), + }), + } as never; + } + }); + + // Mock insert for new routing + mockDbInsert([newRouting]); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/server_down', + payload: { pushEnabled: false }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.eventType).toBe('server_down'); + expect(body.pushEnabled).toBe(false); + expect(db.insert).toHaveBeenCalled(); + }); + + it('rejects invalid event type with 400', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/invalid_event_type', + payload: { discordEnabled: false }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Invalid event type'); + }); + + it('rejects invalid request body with 400', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/violation_detected', + payload: { discordEnabled: 'not-a-boolean' }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Invalid request body'); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/violation_detected', + payload: { discordEnabled: false }, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can update notification routing'); + }); + + it('updates multiple channel settings at once', async () => { + app = await buildTestApp(ownerUser); + + const existingRouting = createMockRouting('violation_detected'); + const updatedRouting = { + ...existingRouting, + discordEnabled: false, + webhookEnabled: false, + pushEnabled: true, + }; + + // Track select calls + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + const routing = selectCallCount === 1 ? existingRouting : updatedRouting; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([routing]), + }), + }), + } as never; + }); + + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/violation_detected', + payload: { + discordEnabled: false, + webhookEnabled: false, + pushEnabled: true, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.discordEnabled).toBe(false); + expect(body.webhookEnabled).toBe(false); + expect(body.pushEnabled).toBe(true); + }); + + it('handles partial updates', async () => { + app = await buildTestApp(ownerUser); + + const existingRouting = createMockRouting('stream_started', { + discordEnabled: true, + webhookEnabled: true, + pushEnabled: true, + }); + + // Only discord changed + const updatedRouting = { ...existingRouting, discordEnabled: false }; + + // Track select calls + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + const routing = selectCallCount === 1 ? existingRouting : updatedRouting; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([routing]), + }), + }), + } as never; + }); + + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings/notifications/routing/stream_started', + payload: { discordEnabled: false }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.discordEnabled).toBe(false); + // Others should remain unchanged + expect(body.webhookEnabled).toBe(true); + expect(body.pushEnabled).toBe(true); + }); + }); +}); + +describe('Channel Routing Helper Functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset all mock implementations + vi.mocked(db.select).mockReset(); + vi.mocked(db.insert).mockReset(); + vi.mocked(db.update).mockReset(); + }); + + describe('getChannelRouting', () => { + it('returns routing for existing event type', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + discordEnabled: true, + webhookEnabled: false, + pushEnabled: true, + }, + ]), + }), + }), + }) as never); + + const routing = await getChannelRouting('violation_detected'); + + expect(routing.discordEnabled).toBe(true); + expect(routing.webhookEnabled).toBe(false); + expect(routing.pushEnabled).toBe(true); + }); + + it('returns defaults for high-priority events with no routing', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + const routing = await getChannelRouting('violation_detected'); + + // High-priority events default to enabled + expect(routing.discordEnabled).toBe(true); + expect(routing.webhookEnabled).toBe(true); + expect(routing.pushEnabled).toBe(true); + }); + + it('returns defaults for low-priority events with no routing', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + const routing = await getChannelRouting('stream_started'); + + // Low-priority events default to disabled + expect(routing.discordEnabled).toBe(false); + expect(routing.webhookEnabled).toBe(false); + expect(routing.pushEnabled).toBe(false); + }); + + it('returns defaults for trust_score_changed (low-priority)', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + const routing = await getChannelRouting('trust_score_changed'); + + expect(routing.discordEnabled).toBe(false); + expect(routing.webhookEnabled).toBe(false); + expect(routing.pushEnabled).toBe(false); + }); + }); + + describe('getAllChannelRouting', () => { + it('returns map of all routing configuration', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockResolvedValue([ + { + eventType: 'violation_detected', + discordEnabled: true, + webhookEnabled: true, + pushEnabled: true, + }, + { + eventType: 'stream_started', + discordEnabled: false, + webhookEnabled: false, + pushEnabled: false, + }, + { + eventType: 'server_down', + discordEnabled: true, + webhookEnabled: true, + pushEnabled: false, + }, + ]), + }) as never); + + const routingMap = await getAllChannelRouting(); + + expect(routingMap.size).toBe(3); + expect(routingMap.get('violation_detected')).toEqual({ + discordEnabled: true, + webhookEnabled: true, + pushEnabled: true, + }); + expect(routingMap.get('stream_started')).toEqual({ + discordEnabled: false, + webhookEnabled: false, + pushEnabled: false, + }); + expect(routingMap.get('server_down')).toEqual({ + discordEnabled: true, + webhookEnabled: true, + pushEnabled: false, + }); + }); + + it('returns empty map when no routing exists', async () => { + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockResolvedValue([]), + }) as never); + + const routingMap = await getAllChannelRouting(); + + expect(routingMap.size).toBe(0); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/dashboard.test.ts b/apps/server/src/routes/__tests__/dashboard.test.ts new file mode 100644 index 0000000..ad59301 --- /dev/null +++ b/apps/server/src/routes/__tests__/dashboard.test.ts @@ -0,0 +1,399 @@ +/** + * Dashboard stats route tests + * + * Tests the API endpoint for dashboard summary metrics: + * - GET /dashboard - Dashboard summary metrics (active streams, plays, watch time, alerts) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser, ActiveSession, DashboardStats } from '@tracearr/shared'; +import { REDIS_KEYS } from '@tracearr/shared'; + +// Mock the prepared statements module +vi.mock('../../db/prepared.js', () => ({ + playsCountSince: { + execute: vi.fn(), + }, + watchTimeSince: { + execute: vi.fn(), + }, + violationsCountSince: { + execute: vi.fn(), + }, + uniqueUsersSince: { + execute: vi.fn(), + }, +})); + +// Mock cache service - need to provide getAllActiveSessions for active stream count +const mockGetAllActiveSessions = vi.fn().mockResolvedValue([]); +vi.mock('../../services/cache.js', () => ({ + getCacheService: vi.fn(() => ({ + getAllActiveSessions: mockGetAllActiveSessions, + })), +})); + +// Import the mocked modules and the routes +import { + playsCountSince, + watchTimeSince, + violationsCountSince, + uniqueUsersSince, +} from '../../db/prepared.js'; +import { dashboardRoutes } from '../stats/dashboard.js'; + +/** + * Build a test Fastify instance with mocked auth and redis + */ +async function buildTestApp( + authUser: AuthUser, + redisMock?: { get: ReturnType; setex: ReturnType } +): Promise { + const app = Fastify({ logger: false }); + + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: any) => { + request.user = authUser; + }); + + // Mock Redis + app.decorate( + 'redis', + (redisMock ?? { + get: vi.fn().mockResolvedValue(null), + setex: vi.fn().mockResolvedValue('OK'), + }) as never + ); + + await app.register(dashboardRoutes, { prefix: '/stats' }); + + return app; +} + +function createOwnerUser(serverIds?: string[]): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: serverIds ?? [randomUUID()], + }; +} + +function createActiveSession(overrides: Partial = {}): ActiveSession { + const serverId = overrides.serverId ?? randomUUID(); + return { + id: overrides.id ?? randomUUID(), + sessionKey: overrides.sessionKey ?? 'session-123', + serverId, + serverUserId: overrides.serverUserId ?? randomUUID(), + state: overrides.state ?? 'playing', + mediaType: overrides.mediaType ?? 'movie', + mediaTitle: overrides.mediaTitle ?? 'Test Movie', + grandparentTitle: overrides.grandparentTitle ?? null, + seasonNumber: overrides.seasonNumber ?? null, + episodeNumber: overrides.episodeNumber ?? null, + year: overrides.year ?? 2024, + thumbPath: overrides.thumbPath ?? '/library/metadata/123/thumb', + ratingKey: overrides.ratingKey ?? 'media-123', + externalSessionId: overrides.externalSessionId ?? null, + startedAt: overrides.startedAt ?? new Date(), + stoppedAt: overrides.stoppedAt ?? null, + durationMs: overrides.durationMs ?? 0, + progressMs: overrides.progressMs ?? 0, + totalDurationMs: overrides.totalDurationMs ?? 7200000, + lastPausedAt: overrides.lastPausedAt ?? null, + pausedDurationMs: overrides.pausedDurationMs ?? 0, + referenceId: overrides.referenceId ?? null, + watched: overrides.watched ?? false, + ipAddress: overrides.ipAddress ?? '192.168.1.100', + geoCity: overrides.geoCity ?? 'New York', + geoRegion: overrides.geoRegion ?? 'NY', + geoCountry: overrides.geoCountry ?? 'US', + geoLat: overrides.geoLat ?? 40.7128, + geoLon: overrides.geoLon ?? -74.006, + playerName: overrides.playerName ?? 'Chrome', + deviceId: overrides.deviceId ?? 'device-123', + product: overrides.product ?? 'Plex Web', + device: overrides.device ?? 'Chrome', + platform: overrides.platform ?? 'Chrome', + quality: overrides.quality ?? '1080p', + isTranscode: overrides.isTranscode ?? false, + bitrate: overrides.bitrate ?? 20000, + user: overrides.user ?? { + id: randomUUID(), + username: 'testuser', + thumbUrl: null, + identityName: null, + }, + server: overrides.server ?? { + id: serverId, + name: 'Test Server', + type: 'plex', + }, + }; +} + +describe('Dashboard Stats Routes', () => { + let app: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /stats/dashboard', () => { + it('should return cached stats when available', async () => { + const ownerUser = createOwnerUser(); + const cachedStats: DashboardStats = { + activeStreams: 5, + todayPlays: 25, + watchTimeHours: 12.5, + alertsLast24h: 3, + activeUsersToday: 8, + }; + + const redisMock = { + get: vi.fn().mockResolvedValue(JSON.stringify(cachedStats)), + setex: vi.fn().mockResolvedValue('OK'), + }; + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toEqual(cachedStats); + expect(redisMock.get).toHaveBeenCalledWith(REDIS_KEYS.DASHBOARD_STATS); + // Should not call database when cache hit + expect(playsCountSince.execute).not.toHaveBeenCalled(); + }); + + it('should compute stats when cache is empty', async () => { + const ownerUser = createOwnerUser(); + + const redisMock = { + get: vi.fn().mockResolvedValue(null), + setex: vi.fn().mockResolvedValue('OK'), + }; + + // Mock prepared statement results + vi.mocked(playsCountSince.execute).mockResolvedValue([{ count: 15 }]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([{ totalMs: 18000000 }]); // 5 hours + vi.mocked(violationsCountSince.execute).mockResolvedValue([{ count: 2 }]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([{ count: 6 }]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.todayPlays).toBe(15); + expect(body.watchTimeHours).toBe(5); + expect(body.alertsLast24h).toBe(2); + expect(body.activeUsersToday).toBe(6); + expect(body.activeStreams).toBe(0); + + // Should cache the results + expect(redisMock.setex).toHaveBeenCalledWith( + REDIS_KEYS.DASHBOARD_STATS, + 60, + expect.any(String) + ); + }); + + it('should count active sessions from cache', async () => { + const ownerUser = createOwnerUser(); + const activeSessions = [createActiveSession(), createActiveSession(), createActiveSession()]; + + // Mock the cache service to return active sessions + mockGetAllActiveSessions.mockResolvedValueOnce(activeSessions); + + const redisMock = { + get: vi.fn().mockResolvedValueOnce(null), // No dashboard cache + setex: vi.fn().mockResolvedValue('OK'), + }; + + vi.mocked(playsCountSince.execute).mockResolvedValue([{ count: 10 }]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([{ totalMs: 7200000 }]); // 2 hours + vi.mocked(violationsCountSince.execute).mockResolvedValue([{ count: 0 }]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([{ count: 3 }]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.activeStreams).toBe(3); + expect(body.todayPlays).toBe(10); + expect(body.watchTimeHours).toBe(2); + }); + + it('should handle invalid JSON in dashboard cache gracefully', async () => { + const ownerUser = createOwnerUser(); + + const redisMock = { + get: vi + .fn() + .mockResolvedValueOnce('invalid json') // Invalid dashboard cache + .mockResolvedValueOnce(null), // No active sessions + setex: vi.fn().mockResolvedValue('OK'), + }; + + vi.mocked(playsCountSince.execute).mockResolvedValue([{ count: 5 }]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([{ totalMs: 3600000 }]); + vi.mocked(violationsCountSince.execute).mockResolvedValue([{ count: 1 }]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([{ count: 2 }]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.todayPlays).toBe(5); + }); + + it('should handle invalid JSON in active sessions cache gracefully', async () => { + const ownerUser = createOwnerUser(); + + // Cache service handles invalid JSON internally and returns empty array + mockGetAllActiveSessions.mockResolvedValueOnce([]); + + const redisMock = { + get: vi.fn().mockResolvedValueOnce(null), // No dashboard cache + setex: vi.fn().mockResolvedValue('OK'), + }; + + vi.mocked(playsCountSince.execute).mockResolvedValue([{ count: 8 }]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([{ totalMs: 0 }]); + vi.mocked(violationsCountSince.execute).mockResolvedValue([{ count: 0 }]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([{ count: 4 }]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.activeStreams).toBe(0); + expect(body.todayPlays).toBe(8); + }); + + it('should handle null results from prepared statements', async () => { + const ownerUser = createOwnerUser(); + + const redisMock = { + get: vi.fn().mockResolvedValue(null), + setex: vi.fn().mockResolvedValue('OK'), + }; + + vi.mocked(playsCountSince.execute).mockResolvedValue([]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([]); + vi.mocked(violationsCountSince.execute).mockResolvedValue([]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.todayPlays).toBe(0); + expect(body.watchTimeHours).toBe(0); + expect(body.alertsLast24h).toBe(0); + expect(body.activeUsersToday).toBe(0); + }); + + it('should round watch time to one decimal place', async () => { + const ownerUser = createOwnerUser(); + + const redisMock = { + get: vi.fn().mockResolvedValue(null), + setex: vi.fn().mockResolvedValue('OK'), + }; + + // 5.555... hours = 20000000 ms + vi.mocked(playsCountSince.execute).mockResolvedValue([{ count: 0 }]); + vi.mocked(watchTimeSince.execute).mockResolvedValue([{ totalMs: 20000000 }]); + vi.mocked(violationsCountSince.execute).mockResolvedValue([{ count: 0 }]); + vi.mocked(uniqueUsersSince.execute).mockResolvedValue([{ count: 0 }]); + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.watchTimeHours).toBe(5.6); + }); + + it('should reject access to server not in user access list', async () => { + const serverId1 = randomUUID(); + const serverId2 = randomUUID(); + // Non-owner user only has access to serverId1 + const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [serverId1], + }; + + app = await buildTestApp(viewerUser); + + // Try to access stats for serverId2 (not in user's serverIds) + const response = await app.inject({ + method: 'GET', + url: `/stats/dashboard?serverId=${serverId2}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should reject invalid serverId format', async () => { + const ownerUser = createOwnerUser(); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/stats/dashboard?serverId=not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/debug.test.ts b/apps/server/src/routes/__tests__/debug.test.ts new file mode 100644 index 0000000..5385796 --- /dev/null +++ b/apps/server/src/routes/__tests__/debug.test.ts @@ -0,0 +1,652 @@ +/** + * Debug routes unit tests + * + * Tests the hidden debug API endpoints (owner-only): + * - GET /debug/stats - Database statistics + * - DELETE /debug/sessions - Clear all sessions + * - DELETE /debug/violations - Clear all violations + * - DELETE /debug/users - Clear all non-owner users + * - DELETE /debug/servers - Clear all servers + * - DELETE /debug/rules - Clear all rules + * - POST /debug/reset - Full factory reset + * - POST /debug/refresh-aggregates - Refresh TimescaleDB aggregates + * - GET /debug/env - Safe environment info + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock the database module +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + execute: vi.fn(), + }, +})); + +// Import mocked db and routes +import { db } from '../../db/client.js'; +import { debugRoutes } from '../debug.js'; + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + // Register routes + await app.register(debugRoutes, { prefix: '/debug' }); + + return app; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock for db.select() with count queries (Promise.all pattern) + */ +function mockDbSelectCounts(counts: number[]) { + let callIndex = 0; + vi.mocked(db.select).mockImplementation(() => { + const count = counts[callIndex++] ?? 0; + return { + from: vi.fn().mockReturnValue(Promise.resolve([{ count }])), + } as never; + }); +} + +/** + * Create a mock for db.execute() for database size/table queries + */ +function mockDbExecute(results: unknown[]) { + let callIndex = 0; + vi.mocked(db.execute).mockImplementation(() => { + const result = results[callIndex++] ?? { rows: [] }; + return Promise.resolve(result) as never; + }); +} + +/** + * Create a mock for db.delete() + */ +function mockDbDelete(deletedItems: { id: string }[]) { + vi.mocked(db.delete).mockReturnValue({ + returning: vi.fn().mockResolvedValue(deletedItems), + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue(deletedItems), + }), + } as never); +} + +/** + * Create a mock for db.select() for user queries + */ +function mockDbSelectUsers(users: { id: string }[]) { + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(users), + }), + } as never); +} + +/** + * Create a mock for db.update() + */ +function mockDbUpdate() { + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); +} + +describe('Debug Routes', () => { + let app: FastifyInstance; + const ownerUser = createOwnerUser(); + const viewerUser = createViewerUser(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('Authorization', () => { + it('allows owner access to debug routes', async () => { + app = await buildTestApp(ownerUser); + + // Mock for GET /env (simplest endpoint) + const response = await app.inject({ + method: 'GET', + url: '/debug/env', + }); + + expect(response.statusCode).toBe(200); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/debug/env', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Owner access required'); + }); + + it('rejects viewer from all debug endpoints', async () => { + app = await buildTestApp(viewerUser); + + const endpoints = [ + { method: 'GET' as const, url: '/debug/stats' }, + { method: 'DELETE' as const, url: '/debug/sessions' }, + { method: 'DELETE' as const, url: '/debug/violations' }, + { method: 'DELETE' as const, url: '/debug/users' }, + { method: 'DELETE' as const, url: '/debug/servers' }, + { method: 'DELETE' as const, url: '/debug/rules' }, + { method: 'POST' as const, url: '/debug/reset' }, + { method: 'POST' as const, url: '/debug/refresh-aggregates' }, + { method: 'GET' as const, url: '/debug/env' }, + ]; + + for (const { method, url } of endpoints) { + const response = await app.inject({ method, url }); + expect(response.statusCode).toBe(403); + } + }); + }); + + describe('GET /debug/stats', () => { + it('returns database statistics', async () => { + app = await buildTestApp(ownerUser); + + // Mock count queries + mockDbSelectCounts([100, 25, 50, 3, 10]); + + // Mock execute for database size and table sizes + mockDbExecute([ + { rows: [{ size: '256 MB' }] }, + { + rows: [ + { table_name: 'sessions', total_size: '128 MB' }, + { table_name: 'users', total_size: '64 MB' }, + ], + }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/debug/stats', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.counts).toEqual({ + sessions: 100, + violations: 25, + users: 50, + servers: 3, + rules: 10, + }); + expect(body.database.size).toBe('256 MB'); + expect(body.database.tables).toHaveLength(2); + }); + + it('handles empty database', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectCounts([0, 0, 0, 0, 0]); + mockDbExecute([ + { rows: [{ size: '8 KB' }] }, + { rows: [] }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/debug/stats', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.counts.sessions).toBe(0); + expect(body.counts.violations).toBe(0); + expect(body.counts.users).toBe(0); + expect(body.counts.servers).toBe(0); + expect(body.counts.rules).toBe(0); + }); + + it('handles missing count values (undefined)', async () => { + app = await buildTestApp(ownerUser); + + // Mock count queries returning empty arrays (undefined count) + vi.mocked(db.select).mockImplementation(() => { + return { + from: vi.fn().mockReturnValue(Promise.resolve([])), // Empty array, no count property + } as never; + }); + + mockDbExecute([ + { rows: [{ size: '8 KB' }] }, + { rows: [] }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/debug/stats', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + // Should fallback to 0 for all counts + expect(body.counts.sessions).toBe(0); + expect(body.counts.violations).toBe(0); + expect(body.counts.users).toBe(0); + expect(body.counts.servers).toBe(0); + expect(body.counts.rules).toBe(0); + }); + + it('handles missing database size', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectCounts([100, 25, 50, 3, 10]); + + // Mock execute with empty rows for database size + mockDbExecute([ + { rows: [] }, // No size row + { rows: [] }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/debug/stats', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.database.size).toBe('unknown'); + }); + }); + + describe('DELETE /debug/sessions', () => { + it('deletes all sessions and violations', async () => { + app = await buildTestApp(ownerUser); + + // Mock delete for violations first, then sessions + let deleteCallIndex = 0; + vi.mocked(db.delete).mockImplementation(() => { + const items = + deleteCallIndex === 0 + ? [{ id: 'v1' }, { id: 'v2' }] // violations + : [{ id: 's1' }, { id: 's2' }, { id: 's3' }]; // sessions + deleteCallIndex++; + return { + returning: vi.fn().mockResolvedValue(items), + } as never; + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/sessions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted.sessions).toBe(3); + expect(body.deleted.violations).toBe(2); + }); + + it('handles no sessions to delete', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.delete).mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + } as never); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/sessions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted.sessions).toBe(0); + expect(body.deleted.violations).toBe(0); + }); + }); + + describe('DELETE /debug/violations', () => { + it('deletes all violations', async () => { + app = await buildTestApp(ownerUser); + + mockDbDelete([{ id: 'v1' }, { id: 'v2' }, { id: 'v3' }]); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/violations', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(3); + }); + + it('handles no violations to delete', async () => { + app = await buildTestApp(ownerUser); + + mockDbDelete([]); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/violations', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(0); + }); + }); + + describe('DELETE /debug/users', () => { + it('deletes non-owner users', async () => { + app = await buildTestApp(ownerUser); + + // Mock select to find non-owner users + mockDbSelectUsers([{ id: 'user-1' }, { id: 'user-2' }]); + + // Mock delete operations + let deleteCallIndex = 0; + vi.mocked(db.delete).mockImplementation(() => { + const result = + deleteCallIndex < 2 + ? { where: vi.fn().mockResolvedValue(undefined) } + : { + where: vi.fn().mockReturnValue({ + returning: vi + .fn() + .mockResolvedValue([{ id: 'user-1' }, { id: 'user-2' }]), + }), + }; + deleteCallIndex++; + return result as never; + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/users', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(2); + }); + + it('handles no non-owner users', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectUsers([]); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/users', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(0); + }); + }); + + describe('DELETE /debug/servers', () => { + it('deletes all servers', async () => { + app = await buildTestApp(ownerUser); + + mockDbDelete([{ id: 'server-1' }, { id: 'server-2' }]); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(2); + }); + }); + + describe('DELETE /debug/rules', () => { + it('deletes all rules and violations first', async () => { + app = await buildTestApp(ownerUser); + + // Mock delete - first for violations (no returning), then for rules (with returning) + let deleteCallIndex = 0; + vi.mocked(db.delete).mockImplementation(() => { + deleteCallIndex++; + if (deleteCallIndex === 1) { + // violations - just resolves + return Promise.resolve() as never; + } else { + // rules - returns deleted items + return { + returning: vi + .fn() + .mockResolvedValue([{ id: 'rule-1' }, { id: 'rule-2' }]), + } as never; + } + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/debug/rules', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.deleted).toBe(2); + }); + }); + + describe('POST /debug/reset', () => { + it('performs full factory reset', async () => { + app = await buildTestApp(ownerUser); + + // Mock all delete operations + vi.mocked(db.delete).mockReturnValue(Promise.resolve() as never); + + // Mock update for settings reset + mockDbUpdate(); + + const response = await app.inject({ + method: 'POST', + url: '/debug/reset', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.message).toContain('Factory reset complete'); + + // Verify delete was called 11 times (violations, terminationLogs, sessions, rules, + // notificationChannelRouting, notificationPreferences, mobileSessions, mobileTokens, + // serverUsers, users, servers) + expect(db.delete).toHaveBeenCalledTimes(11); + + // Verify settings update was called + expect(db.update).toHaveBeenCalled(); + }); + }); + + describe('POST /debug/refresh-aggregates', () => { + it('refreshes continuous aggregates successfully', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + + const response = await app.inject({ + method: 'POST', + url: '/debug/refresh-aggregates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.message).toBe('Aggregates refreshed'); + + // Should call execute twice (hourly_stats and daily_stats) + expect(db.execute).toHaveBeenCalledTimes(2); + }); + + it('handles aggregate refresh failure gracefully', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.execute).mockRejectedValue( + new Error('Aggregates not configured') + ); + + const response = await app.inject({ + method: 'POST', + url: '/debug/refresh-aggregates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(false); + expect(body.message).toContain('not configured or refresh failed'); + }); + }); + + describe('GET /debug/env', () => { + it('returns safe environment info', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/debug/env', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Check structure + expect(body).toHaveProperty('nodeVersion'); + expect(body).toHaveProperty('platform'); + expect(body).toHaveProperty('arch'); + expect(body).toHaveProperty('uptime'); + expect(body).toHaveProperty('memoryUsage'); + expect(body).toHaveProperty('env'); + + // Check memory usage format + expect(body.memoryUsage.heapUsed).toMatch(/^\d+ MB$/); + expect(body.memoryUsage.heapTotal).toMatch(/^\d+ MB$/); + expect(body.memoryUsage.rss).toMatch(/^\d+ MB$/); + + // Check env does not expose secrets + expect(body.env.DATABASE_URL).toMatch(/^\[(set|not set)\]$/); + expect(body.env.REDIS_URL).toMatch(/^\[(set|not set)\]$/); + expect(body.env.ENCRYPTION_KEY).toMatch(/^\[(set|not set)\]$/); + }); + + it('masks sensitive environment variables', async () => { + app = await buildTestApp(ownerUser); + + // Set env vars temporarily + process.env.DATABASE_URL = 'postgres://secret:password@localhost/db'; + process.env.REDIS_URL = 'redis://secret@localhost:6379'; + + const response = await app.inject({ + method: 'GET', + url: '/debug/env', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Should show [set] not the actual values + expect(body.env.DATABASE_URL).toBe('[set]'); + expect(body.env.REDIS_URL).toBe('[set]'); + + // Clean up + delete process.env.DATABASE_URL; + delete process.env.REDIS_URL; + }); + + it('shows [not set] for unset environment variables', async () => { + app = await buildTestApp(ownerUser); + + // Ensure env vars are NOT set + const origDbUrl = process.env.DATABASE_URL; + const origRedisUrl = process.env.REDIS_URL; + const origEncKey = process.env.ENCRYPTION_KEY; + delete process.env.DATABASE_URL; + delete process.env.REDIS_URL; + delete process.env.ENCRYPTION_KEY; + + const response = await app.inject({ + method: 'GET', + url: '/debug/env', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Should show [not set] for unset env vars + expect(body.env.DATABASE_URL).toBe('[not set]'); + expect(body.env.REDIS_URL).toBe('[not set]'); + expect(body.env.ENCRYPTION_KEY).toBe('[not set]'); + + // Restore original values + if (origDbUrl) process.env.DATABASE_URL = origDbUrl; + if (origRedisUrl) process.env.REDIS_URL = origRedisUrl; + if (origEncKey) process.env.ENCRYPTION_KEY = origEncKey; + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/images.test.ts b/apps/server/src/routes/__tests__/images.test.ts new file mode 100644 index 0000000..8f969f3 --- /dev/null +++ b/apps/server/src/routes/__tests__/images.test.ts @@ -0,0 +1,429 @@ +/** + * Image routes unit tests + * + * Tests the API endpoints for image proxy functionality: + * - GET /images/proxy - Proxy an image from a media server + * - GET /images/avatar - Get a user avatar + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; + +// Mock image proxy service +vi.mock('../../services/imageProxy.js', () => ({ + proxyImage: vi.fn(), +})); + +// Import mocked service and routes +import { proxyImage } from '../../services/imageProxy.js'; +import { imageRoutes } from '../images.js'; + +/** + * Build a test Fastify instance + * Note: Image routes are public (no auth required) + */ +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Register routes + await app.register(imageRoutes, { prefix: '/images' }); + + return app; +} + +describe('Image Routes', () => { + let app: FastifyInstance; + const mockProxyImage = vi.mocked(proxyImage); + const validServerId = randomUUID(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /images/proxy', () => { + it('returns proxied image with correct headers', async () => { + app = await buildTestApp(); + + const mockImageData = Buffer.from('fake-image-data'); + mockProxyImage.mockResolvedValue({ + data: mockImageData, + contentType: 'image/jpeg', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/library/metadata/123/thumb/456', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('image/jpeg'); + expect(response.headers['x-cache']).toBe('MISS'); + expect(response.headers['cache-control']).toContain('public'); + expect(response.rawPayload).toEqual(mockImageData); + + // Verify service was called with defaults + expect(mockProxyImage).toHaveBeenCalledWith({ + serverId: validServerId, + imagePath: '/library/metadata/123/thumb/456', + width: 300, + height: 450, + fallback: 'poster', + }); + }); + + it('returns cache HIT header when image is cached', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('cached-image'), + contentType: 'image/png', + cached: true, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/some/image/path', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['x-cache']).toBe('HIT'); + }); + + it('accepts custom width and height', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('image'), + contentType: 'image/webp', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + width: '500', + height: '750', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockProxyImage).toHaveBeenCalledWith( + expect.objectContaining({ + width: 500, + height: 750, + }) + ); + }); + + it('accepts avatar fallback type', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('image'), + contentType: 'image/png', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + fallback: 'avatar', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockProxyImage).toHaveBeenCalledWith( + expect.objectContaining({ + fallback: 'avatar', + }) + ); + }); + + it('accepts art fallback type', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('image'), + contentType: 'image/png', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + fallback: 'art', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockProxyImage).toHaveBeenCalledWith( + expect.objectContaining({ + fallback: 'art', + }) + ); + }); + + it('rejects missing server ID', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + url: '/some/path', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error).toBe('Invalid query parameters'); + }); + + it('rejects invalid server ID format', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: 'not-a-uuid', + url: '/some/path', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error).toBe('Invalid query parameters'); + }); + + it('rejects missing URL', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects width below minimum', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + width: '5', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects width above maximum', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + width: '3000', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects invalid fallback type', async () => { + app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/images/proxy', + query: { + server: validServerId, + url: '/path', + fallback: 'invalid', + }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /images/avatar', () => { + it('returns avatar from media server when server and url provided', async () => { + app = await buildTestApp(); + + const mockImageData = Buffer.from('avatar-data'); + mockProxyImage.mockResolvedValue({ + data: mockImageData, + contentType: 'image/png', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/avatar', + query: { + server: validServerId, + url: '/users/123/avatar', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('image/png'); + expect(response.headers['cache-control']).toContain('public'); + expect(response.rawPayload).toEqual(mockImageData); + + expect(mockProxyImage).toHaveBeenCalledWith({ + serverId: validServerId, + imagePath: '/users/123/avatar', + width: 100, + height: 100, + fallback: 'avatar', + }); + }); + + it('accepts custom size parameter', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('avatar'), + contentType: 'image/jpeg', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/avatar', + query: { + server: validServerId, + url: '/avatar', + size: '200', + }, + }); + + expect(response.statusCode).toBe(200); + expect(mockProxyImage).toHaveBeenCalledWith( + expect.objectContaining({ + width: 200, + height: 200, + }) + ); + }); + + it('returns fallback avatar when no server provided', async () => { + app = await buildTestApp(); + + const mockFallbackData = Buffer.from('fallback-avatar'); + mockProxyImage.mockResolvedValue({ + data: mockFallbackData, + contentType: 'image/svg+xml', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/avatar', + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toContain('public'); + + expect(mockProxyImage).toHaveBeenCalledWith({ + serverId: 'fallback', + imagePath: 'fallback', + width: 100, + height: 100, + fallback: 'avatar', + }); + }); + + it('returns fallback avatar when server provided but no url', async () => { + app = await buildTestApp(); + + const mockFallbackData = Buffer.from('fallback-avatar'); + mockProxyImage.mockResolvedValue({ + data: mockFallbackData, + contentType: 'image/svg+xml', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/avatar', + query: { + server: validServerId, + }, + }); + + expect(response.statusCode).toBe(200); + + // Without URL, should use fallback + expect(mockProxyImage).toHaveBeenCalledWith({ + serverId: 'fallback', + imagePath: 'fallback', + width: 100, + height: 100, + fallback: 'avatar', + }); + }); + + it('sets longer cache for fallback avatars', async () => { + app = await buildTestApp(); + + mockProxyImage.mockResolvedValue({ + data: Buffer.from('fallback'), + contentType: 'image/svg+xml', + cached: false, + }); + + const response = await app.inject({ + method: 'GET', + url: '/images/avatar', + }); + + expect(response.statusCode).toBe(200); + // Fallback should have longer cache (86400 seconds = 1 day) + expect(response.headers['cache-control']).toContain('max-age=86400'); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/import.test.ts b/apps/server/src/routes/__tests__/import.test.ts new file mode 100644 index 0000000..38645e9 --- /dev/null +++ b/apps/server/src/routes/__tests__/import.test.ts @@ -0,0 +1,581 @@ +/** + * Import routes unit tests + * + * Tests the API endpoints for data import from external sources: + * - POST /import/tautulli - Start Tautulli history import + * - POST /import/tautulli/test - Test Tautulli connection + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock class for TautulliService +let mockTautulliInstance: { + testConnection: ReturnType; + getUsers: ReturnType; + getHistory: ReturnType; +}; + +// Mock external services +vi.mock('../../services/tautulli.js', () => { + const MockTautulliService = vi.fn().mockImplementation(function ( + this: typeof mockTautulliInstance + ) { + // Copy mock instance methods to this + this.testConnection = mockTautulliInstance.testConnection; + this.getUsers = mockTautulliInstance.getUsers; + this.getHistory = mockTautulliInstance.getHistory; + }); + // Add static method + (MockTautulliService as unknown as { importHistory: ReturnType }).importHistory = vi.fn(); + return { TautulliService: MockTautulliService }; +}); + +vi.mock('../../services/cache.js', () => ({ + getPubSubService: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../services/sync.js', () => ({ + syncServer: vi.fn().mockResolvedValue(undefined), +})); + +// Mock import queue functions +vi.mock('../../jobs/importQueue.js', () => ({ + enqueueImport: vi.fn().mockRejectedValue(new Error('Queue not available')), + getImportStatus: vi.fn().mockResolvedValue(null), + cancelImport: vi.fn().mockResolvedValue(false), + getImportQueueStats: vi.fn().mockResolvedValue(null), + getActiveImportForServer: vi.fn().mockResolvedValue(null), +})); + +// Import mocked services and routes +import { TautulliService } from '../../services/tautulli.js'; +import { syncServer } from '../../services/sync.js'; +import { + enqueueImport, + getImportStatus, + cancelImport, + getImportQueueStats, + getActiveImportForServer, +} from '../../jobs/importQueue.js'; +import { importRoutes } from '../import.js'; + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + // Register routes + await app.register(importRoutes, { prefix: '/import' }); + + return app; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +describe('Import Routes', () => { + let app: FastifyInstance; + const ownerUser = createOwnerUser(); + const viewerUser = createViewerUser(); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock instance with default behavior + mockTautulliInstance = { + testConnection: vi.fn().mockResolvedValue(false), + getUsers: vi.fn().mockResolvedValue([]), + getHistory: vi.fn().mockResolvedValue({ total: 0 }), + }; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('POST /import/tautulli', () => { + const validServerId = randomUUID(); + + it('starts import for owner user', async () => { + app = await buildTestApp(ownerUser); + + // Mock TautulliService.importHistory static method + const mockImportHistory = vi.fn().mockResolvedValue({ imported: 100 }); + (TautulliService as unknown as { importHistory: ReturnType }).importHistory = + mockImportHistory; + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: validServerId }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + // When queue is not available (no Redis in tests), falls back to direct execution + expect(body.status).toBe('started'); + expect(body.message).toContain('Import started'); + + // Verify server sync was called + expect(syncServer).toHaveBeenCalledWith(validServerId, { + syncUsers: true, + syncLibraries: false, + }); + }); + + it('rejects non-owner users', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: validServerId }, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can import data'); + }); + + it('rejects missing serverId', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('serverId is required'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: 123 }, // Should be string + }); + + expect(response.statusCode).toBe(400); + }); + + it('handles sync failure gracefully', async () => { + app = await buildTestApp(ownerUser); + + // Mock sync failure + vi.mocked(syncServer).mockRejectedValueOnce(new Error('Sync failed')); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: validServerId }, + }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body.message).toContain('Failed to sync server'); + }); + }); + + describe('POST /import/tautulli/test', () => { + const validUrl = 'http://localhost:8181'; + const validApiKey = 'test-api-key-12345'; + + it('returns success when connection works', async () => { + // Configure mock instance for successful connection + mockTautulliInstance.testConnection.mockResolvedValue(true); + mockTautulliInstance.getUsers.mockResolvedValue([{ user_id: 1 }, { user_id: 2 }]); + mockTautulliInstance.getHistory.mockResolvedValue({ total: 1500 }); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl, apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + success: true, + message: 'Connection successful', + users: 2, + historyRecords: 1500, + }); + }); + + it('returns failure when connection fails', async () => { + // Configure mock instance for failed connection + mockTautulliInstance.testConnection.mockResolvedValue(false); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl, apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + success: false, + message: 'Connection failed. Please check URL and API key.', + }); + }); + + it('handles connection error gracefully', async () => { + // Configure mock instance for connection error + mockTautulliInstance.testConnection.mockRejectedValue(new Error('Network unreachable')); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl, apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + success: false, + message: 'Network unreachable', + }); + }); + + it('handles non-Error exceptions', async () => { + // Configure mock instance for non-Error exception + mockTautulliInstance.testConnection.mockRejectedValue('String error'); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl, apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + success: false, + message: 'Connection failed', + }); + }); + + it('rejects non-owner users', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl, apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can test Tautulli connection'); + }); + + it('rejects missing URL', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { apiKey: validApiKey }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('URL and API key are required'); + }); + + it('rejects missing API key', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: { url: validUrl }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('URL and API key are required'); + }); + + it('rejects empty request body', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli/test', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /import/tautulli/active/:serverId', () => { + const serverId = randomUUID(); + + it('returns active: false when no import is active', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: `/import/tautulli/active/${serverId}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ active: false }); + }); + + it('returns active: true with status when import is active', async () => { + app = await buildTestApp(ownerUser); + + const mockStatus = { + jobId: 'job-123', + state: 'active', + progress: { processed: 50, total: 100 }, + }; + + vi.mocked(getActiveImportForServer).mockResolvedValueOnce('job-123'); + vi.mocked(getImportStatus).mockResolvedValueOnce(mockStatus); + + const response = await app.inject({ + method: 'GET', + url: `/import/tautulli/active/${serverId}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.active).toBe(true); + expect(body.jobId).toBe('job-123'); + }); + + it('returns active: false when job exists but status is null', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(getActiveImportForServer).mockResolvedValueOnce('job-123'); + vi.mocked(getImportStatus).mockResolvedValueOnce(null); + + const response = await app.inject({ + method: 'GET', + url: `/import/tautulli/active/${serverId}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ active: false }); + }); + }); + + describe('GET /import/tautulli/:jobId', () => { + it('returns job status when found', async () => { + app = await buildTestApp(ownerUser); + + const mockStatus = { + jobId: 'job-456', + state: 'completed', + progress: { processed: 100, total: 100 }, + }; + + vi.mocked(getImportStatus).mockResolvedValueOnce(mockStatus); + + const response = await app.inject({ + method: 'GET', + url: '/import/tautulli/job-456', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.jobId).toBe('job-456'); + expect(body.state).toBe('completed'); + }); + + it('returns 404 when job not found', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(getImportStatus).mockResolvedValueOnce(null); + + const response = await app.inject({ + method: 'GET', + url: '/import/tautulli/nonexistent-job', + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.message).toBe('Import job not found'); + }); + }); + + describe('DELETE /import/tautulli/:jobId', () => { + it('cancels job successfully for owner', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(cancelImport).mockResolvedValueOnce(true); + + const response = await app.inject({ + method: 'DELETE', + url: '/import/tautulli/job-789', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ status: 'cancelled', jobId: 'job-789' }); + }); + + it('returns 400 when cancel fails', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(cancelImport).mockResolvedValueOnce(false); + + const response = await app.inject({ + method: 'DELETE', + url: '/import/tautulli/job-789', + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Cannot cancel job'); + }); + + it('rejects non-owner users', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/import/tautulli/job-789', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can cancel imports'); + }); + }); + + describe('GET /import/stats', () => { + it('returns queue stats when available', async () => { + app = await buildTestApp(ownerUser); + + const mockStats = { + waiting: 2, + active: 1, + completed: 10, + failed: 0, + delayed: 0, + dlqSize: 0, + }; + + vi.mocked(getImportQueueStats).mockResolvedValueOnce(mockStats); + + const response = await app.inject({ + method: 'GET', + url: '/import/stats', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual(mockStats); + }); + + it('returns 503 when queue is unavailable', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(getImportQueueStats).mockResolvedValueOnce(null); + + const response = await app.inject({ + method: 'GET', + url: '/import/stats', + }); + + expect(response.statusCode).toBe(503); + const body = response.json(); + expect(body.message).toBe('Import queue not available'); + }); + }); + + describe('POST /import/tautulli with queue', () => { + const validServerId = randomUUID(); + + it('returns queued status when queue is available', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(enqueueImport).mockResolvedValueOnce('job-queue-123'); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: validServerId }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.status).toBe('queued'); + expect(body.jobId).toBe('job-queue-123'); + }); + + it('returns conflict when import already in progress', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(enqueueImport).mockRejectedValueOnce( + new Error('Import already in progress for this server') + ); + + const response = await app.inject({ + method: 'POST', + url: '/import/tautulli', + payload: { serverId: validServerId }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.message).toContain('already in progress'); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/mobile-beta-mode.test.ts b/apps/server/src/routes/__tests__/mobile-beta-mode.test.ts new file mode 100644 index 0000000..240a856 --- /dev/null +++ b/apps/server/src/routes/__tests__/mobile-beta-mode.test.ts @@ -0,0 +1,564 @@ +/** + * Mobile routes beta mode tests + * + * Tests MOBILE_BETA_MODE=true behavior: + * - Tokens never expire (100 years) + * - Tokens can be reused (not single-use) + * - No device limit enforcement + * + * This test file sets MOBILE_BETA_MODE before importing the module + * to ensure the env var is read correctly at module load time. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; + +// Set env var BEFORE any imports that might load mobile.ts +process.env.MOBILE_BETA_MODE = 'true'; + +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock the database module +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + }, +})); + +// Mock the termination service +vi.mock('../../services/termination.js', () => ({ + terminateSession: vi.fn(), +})); + +// Import mocked db and routes +import { db } from '../../db/client.js'; +import { mobileRoutes } from '../mobile.js'; + +// Mock Redis +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + eval: vi.fn(), + ttl: vi.fn(), +}; + +// Mock JWT +const mockJwt = { + sign: vi.fn(), + verify: vi.fn(), +}; + +async function buildTestApp(authUser: AuthUser | null): Promise { + const app = Fastify({ logger: false }); + + await app.register(sensible); + + app.decorate('redis', mockRedis as never); + app.decorate('jwt', mockJwt as never); + + app.decorate('authenticate', async (request: unknown) => { + if (authUser) { + (request as { user: AuthUser }).user = authUser; + } + }); + + app.decorate('requireMobile', async (request: unknown) => { + if (authUser) { + (request as { user: AuthUser }).user = authUser; + } + }); + + await app.register(mobileRoutes, { prefix: '/mobile' }); + + return app; +} + +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +function createMockToken(overrides?: Partial<{ + id: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdBy: string; + createdAt: Date; +}>) { + return { + id: overrides?.id ?? randomUUID(), + tokenHash: overrides?.tokenHash ?? 'tokenhash123', + expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 15 * 60 * 1000), + usedAt: overrides?.usedAt ?? null, + createdBy: overrides?.createdBy ?? randomUUID(), + createdAt: overrides?.createdAt ?? new Date(), + }; +} + +describe('Mobile Routes - Beta Mode Enabled', () => { + let app: FastifyInstance; + const ownerUser = createOwnerUser(); + + beforeAll(() => { + // Verify env var is set + expect(process.env.MOBILE_BETA_MODE).toBe('true'); + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.select).mockReset(); + vi.mocked(db.insert).mockReset(); + vi.mocked(db.update).mockReset(); + vi.mocked(db.delete).mockReset(); + vi.mocked(db.transaction).mockReset(); + mockRedis.get.mockReset(); + mockRedis.set.mockReset(); + mockRedis.setex.mockReset(); + mockRedis.del.mockReset(); + mockRedis.eval.mockReset(); + mockRedis.ttl.mockReset(); + mockJwt.sign.mockReset(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('Token reuse in beta mode', () => { + it('accepts already-used tokens', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + // Mock device count check + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 0 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + + // Token has usedAt set - should still be accepted in beta mode + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 3) { + return { from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'Server', type: 'plex' }]) }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + createMockToken({ usedAt: new Date() }), // Already used! + ]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + }), + }), + }; + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-123', + platform: 'ios', + }, + }); + + // In beta mode, already-used tokens should be accepted + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.accessToken).toBe('mock.jwt.token'); + }); + + it('does not mark token as used after pairing', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 0 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + + let tokenUpdateCalled = false; + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 3) { + return { from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'Server', type: 'plex' }]) }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + }), + }), + }; + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockImplementation((_table) => { + // Check if this is the mobileTokens update (marking as used) + // In beta mode, this should NOT be called for mobileTokens + return { + set: vi.fn().mockImplementation((setValues) => { + if (setValues.usedAt) { + tokenUpdateCalled = true; + } + return { + where: vi.fn().mockResolvedValue(undefined), + }; + }), + }; + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-123', + platform: 'ios', + }, + }); + + expect(response.statusCode).toBe(200); + // In beta mode, token should NOT be marked as used + expect(tokenUpdateCalled).toBe(false); + }); + }); + + describe('Device limit in beta mode', () => { + it('allows pairing when at device limit', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + // Mock device count at limit (5) - should still allow in beta mode + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 5 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 3) { + return { from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'Server', type: 'plex' }]) }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + }), + }), + }; + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-new', + platform: 'ios', + }, + }); + + // In beta mode, should succeed even at device limit + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.accessToken).toBe('mock.jwt.token'); + }); + + it('allows pairing when exceeding device limit', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + // Mock device count OVER limit (10 devices) + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 10 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 3) { + return { from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'Server', type: 'plex' }]) }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + }), + }), + }; + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-new', + platform: 'ios', + }, + }); + + // In beta mode, should succeed even with 10+ devices + expect(response.statusCode).toBe(200); + }); + }); + + describe('Token expiry in beta mode', () => { + it('generates tokens with 100 year expiry', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + mockRedis.eval.mockResolvedValue(1); + + let capturedExpiry: Date | null = null; + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + }), + insert: vi.fn().mockImplementation(() => ({ + values: vi.fn().mockImplementation((values: { expiresAt: Date }) => { + capturedExpiry = values.expiresAt; + return Promise.resolve(undefined); + }), + })), + }; + return callback(tx as never); + }); + + const beforeRequest = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(200); + expect(capturedExpiry).not.toBeNull(); + + // Token should expire in ~100 years (with some tolerance) + const expiryMs = capturedExpiry!.getTime() - beforeRequest; + const expectedExpiryMs = 100 * 365 * 24 * 60 * 60 * 1000; // 100 years in ms + // Allow 1 day tolerance for leap year calculations + const tolerance = 24 * 60 * 60 * 1000; + expect(expiryMs).toBeGreaterThanOrEqual(expectedExpiryMs - tolerance); + expect(expiryMs).toBeLessThanOrEqual(expectedExpiryMs + tolerance); + }); + }); + + describe('Token generation in beta mode', () => { + it('allows generating tokens even at device limit', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + mockRedis.eval.mockResolvedValue(1); + + // Mock transaction with different responses for pending tokens vs device count + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 1) { + // First query: pending tokens count - return 0 (below limit) + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + }; + } else { + // Second query: device count - return 5 (at limit) + return { + from: vi.fn().mockResolvedValue([{ count: 5 }]), + }; + } + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + // In beta mode, should succeed even at device limit + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.token).toMatch(/^trr_mob_/); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/mobile.test.ts b/apps/server/src/routes/__tests__/mobile.test.ts new file mode 100644 index 0000000..54a5889 --- /dev/null +++ b/apps/server/src/routes/__tests__/mobile.test.ts @@ -0,0 +1,1990 @@ +/** + * Mobile routes unit tests + * + * Tests the API endpoints for mobile app functionality: + * + * Settings endpoints (owner only): + * - GET /mobile - Get mobile config + * - POST /mobile/enable - Enable mobile access + * - POST /mobile/pair-token - Generate one-time pairing token + * - POST /mobile/disable - Disable mobile access + * - DELETE /mobile/sessions - Revoke all mobile sessions + * - DELETE /mobile/sessions/:id - Revoke single mobile session + * + * Auth endpoints (mobile app): + * - POST /mobile/pair - Exchange pairing token for JWT + * - POST /mobile/refresh - Refresh mobile JWT + * - POST /mobile/push-token - Register push token + * + * Stream management (admin/owner via mobile): + * - POST /mobile/streams/:id/terminate - Terminate a playback session + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock the database module +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + }, +})); + +// Mock the termination service +vi.mock('../../services/termination.js', () => ({ + terminateSession: vi.fn(), +})); + +// Import mocked db, routes, and termination service +import { db } from '../../db/client.js'; +import { mobileRoutes } from '../mobile.js'; +import { terminateSession } from '../../services/termination.js'; + +// Mock Redis +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + eval: vi.fn(), + ttl: vi.fn(), +}; + +// Mock JWT +const mockJwt = { + sign: vi.fn(), + verify: vi.fn(), +}; + +/** + * Build a test Fastify instance with mocked auth and redis + */ +async function buildTestApp(authUser: AuthUser | null): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock Redis decorator (cast to never for test mock) + app.decorate('redis', mockRedis as never); + + // Mock JWT decorator (cast to never for test mock) + app.decorate('jwt', mockJwt as never); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: unknown) => { + if (authUser) { + (request as { user: AuthUser }).user = authUser; + } + }); + + // Mock the requireMobile decorator (validates mobile JWT) + app.decorate('requireMobile', async (request: unknown) => { + if (authUser) { + (request as { user: AuthUser }).user = authUser; + } + }); + + // Register routes + await app.register(mobileRoutes, { prefix: '/mobile' }); + + return app; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock mobile auth user (with deviceId) + */ +function createMobileUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + deviceId: 'device-123', + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock mobile admin user (with deviceId) + */ +function createMobileAdminUser(serverId?: string): AuthUser { + return { + userId: randomUUID(), + username: 'admin', + role: 'admin', + serverIds: serverId ? [serverId] : [randomUUID()], + mobile: true, + deviceId: 'device-admin-123', + }; +} + +/** + * Create a mock mobile viewer user (with deviceId) + */ +function createMobileViewerUser(serverId?: string): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: serverId ? [serverId] : [randomUUID()], + mobile: true, + deviceId: 'device-viewer-123', + }; +} + +/** + * Create a mock mobile session + */ +function createMockSession(overrides?: Partial<{ + id: string; + deviceName: string; + deviceId: string; + platform: 'ios' | 'android'; + refreshTokenHash: string; + expoPushToken: string | null; + deviceSecret: string | null; + lastSeenAt: Date; + createdAt: Date; +}>) { + return { + id: overrides?.id ?? randomUUID(), + deviceName: overrides?.deviceName ?? 'iPhone 15', + deviceId: overrides?.deviceId ?? 'device-123', + platform: overrides?.platform ?? 'ios', + refreshTokenHash: overrides?.refreshTokenHash ?? 'hash123', + expoPushToken: overrides?.expoPushToken ?? null, + deviceSecret: overrides?.deviceSecret ?? null, + lastSeenAt: overrides?.lastSeenAt ?? new Date(), + createdAt: overrides?.createdAt ?? new Date(), + }; +} + +/** + * Create a mock mobile token + */ +function createMockToken(overrides?: Partial<{ + id: string; + tokenHash: string; + expiresAt: Date; + usedAt: Date | null; + createdBy: string; + createdAt: Date; +}>) { + return { + id: overrides?.id ?? randomUUID(), + tokenHash: overrides?.tokenHash ?? 'tokenhash123', + expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 15 * 60 * 1000), + usedAt: overrides?.usedAt ?? null, + createdBy: overrides?.createdBy ?? randomUUID(), + createdAt: overrides?.createdAt ?? new Date(), + }; +} + +describe('Mobile Routes', () => { + let app: FastifyInstance; + const ownerUser = createOwnerUser(); + const viewerUser = createViewerUser(); + const mobileUser = createMobileUser(); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock implementations + vi.mocked(db.select).mockReset(); + vi.mocked(db.insert).mockReset(); + vi.mocked(db.update).mockReset(); + vi.mocked(db.delete).mockReset(); + vi.mocked(db.transaction).mockReset(); + vi.mocked(terminateSession).mockReset(); + mockRedis.get.mockReset(); + mockRedis.set.mockReset(); + mockRedis.setex.mockReset(); + mockRedis.del.mockReset(); + mockRedis.eval.mockReset(); + mockRedis.ttl.mockReset(); + mockJwt.sign.mockReset(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + // ============================================ + // Settings endpoints (owner only) + // ============================================ + + describe('GET /mobile', () => { + it('returns mobile config for owner', async () => { + app = await buildTestApp(ownerUser); + + const mockSessions = [ + createMockSession(), + createMockSession({ id: randomUUID(), deviceName: 'Pixel 8', platform: 'android' }), + ]; + + // Mock db.select chains + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + // Settings query + return { + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never; + } else if (selectCallCount === 2) { + // Sessions query + return { + from: vi.fn().mockResolvedValue(mockSessions), + } as never; + } else if (selectCallCount === 3) { + // Pending tokens count + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + }), + } as never; + } else { + // Server name query + return { + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ name: 'MyServer' }]), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'GET', + url: '/mobile', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.isEnabled).toBe(true); + expect(body.sessions).toHaveLength(2); + expect(body.serverName).toBe('MyServer'); + expect(body.pendingTokens).toBe(1); + expect(body.maxDevices).toBe(5); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/mobile', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can access mobile settings'); + }); + + it('returns empty sessions when none exist', async () => { + app = await buildTestApp(ownerUser); + + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: false }]), + }), + } as never; + } else if (selectCallCount === 2) { + return { from: vi.fn().mockResolvedValue([]) } as never; + } else if (selectCallCount === 3) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ name: 'Tracearr' }]), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'GET', + url: '/mobile', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.isEnabled).toBe(false); + expect(body.sessions).toHaveLength(0); + }); + }); + + describe('POST /mobile/enable', () => { + it('enables mobile access for owner', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); + + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ name: 'MyServer' }]), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/enable', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.isEnabled).toBe(true); + expect(db.update).toHaveBeenCalled(); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/enable', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can enable mobile access'); + }); + }); + + describe('POST /mobile/pair-token', () => { + it('generates pairing token for owner', async () => { + app = await buildTestApp(ownerUser); + + // Mock settings check + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + // Mock rate limit check (Redis eval) + mockRedis.eval.mockResolvedValue(1); + + // Mock transaction + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.token).toMatch(/^trr_mob_/); + expect(body.expiresAt).toBeDefined(); + }); + + it('rejects when mobile not enabled', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: false }]), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Mobile access is not enabled'); + }); + + it('rejects when rate limited', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + mockRedis.eval.mockResolvedValue(4); // Exceeds limit of 3 + mockRedis.ttl.mockResolvedValue(120); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(429); + expect(response.headers['retry-after']).toBe('120'); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can generate pairing tokens'); + }); + + it('rejects when max pending tokens reached', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + mockRedis.eval.mockResolvedValue(1); + + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 3 }]), // Max pending tokens + }), + }), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Maximum of 3 pending tokens allowed'); + }); + }); + + describe('POST /mobile/disable', () => { + it('disables mobile access for owner', async () => { + app = await buildTestApp(ownerUser); + + const mockSessions = [createMockSession()]; + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockResolvedValue(mockSessions), + } as never); + + vi.mocked(db.delete).mockReturnValue( + Promise.resolve() as never + ); + + mockRedis.del.mockResolvedValue(1); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/disable', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(db.update).toHaveBeenCalled(); + expect(db.delete).toHaveBeenCalled(); + expect(mockRedis.del).toHaveBeenCalled(); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/disable', + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toBe('Only server owners can disable mobile access'); + }); + }); + + describe('DELETE /mobile/sessions', () => { + it('revokes all mobile sessions for owner', async () => { + app = await buildTestApp(ownerUser); + + const mockSessions = [ + createMockSession(), + createMockSession({ id: randomUUID(), refreshTokenHash: 'hash456' }), + ]; + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockResolvedValue(mockSessions), + } as never); + + vi.mocked(db.delete).mockReturnValue( + Promise.resolve() as never + ); + + mockRedis.del.mockResolvedValue(1); + + const response = await app.inject({ + method: 'DELETE', + url: '/mobile/sessions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.revokedCount).toBe(2); + expect(mockRedis.del).toHaveBeenCalledTimes(2); + }); + + it('handles empty sessions gracefully', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockResolvedValue([]), + } as never); + + vi.mocked(db.delete).mockReturnValue( + Promise.resolve() as never + ); + + const response = await app.inject({ + method: 'DELETE', + url: '/mobile/sessions', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.revokedCount).toBe(0); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/mobile/sessions', + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('DELETE /mobile/sessions/:id', () => { + it('revokes single mobile session for owner', async () => { + app = await buildTestApp(ownerUser); + + const sessionId = randomUUID(); + const mockSession = createMockSession({ id: sessionId }); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockSession]), + }), + }), + } as never); + + vi.mocked(db.delete).mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + } as never); + + mockRedis.del.mockResolvedValue(1); + + const response = await app.inject({ + method: 'DELETE', + url: `/mobile/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(mockRedis.del).toHaveBeenCalled(); + expect(db.delete).toHaveBeenCalled(); + }); + + it('returns 404 for non-existent session', async () => { + app = await buildTestApp(ownerUser); + + const sessionId = randomUUID(); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'DELETE', + url: `/mobile/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.message).toBe('Mobile session not found'); + }); + + it('rejects invalid session ID format', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/mobile/sessions/invalid-id', + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Invalid session ID format'); + }); + + it('rejects non-owner access with 403', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'DELETE', + url: `/mobile/sessions/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + // ============================================ + // Auth endpoints (mobile app) + // ============================================ + + describe('POST /mobile/pair', () => { + const validPairPayload = { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-123', + platform: 'ios', + }; + + it('exchanges valid pairing token for JWT', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + + // Mock device count check + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + // Sessions count + return { + from: vi.fn().mockResolvedValue([{ count: 0 }]), + } as never; + } else { + // Existing session check + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + // Mock transaction with call tracking for different query patterns + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + // Call 1: mobileTokens lookup with .where().for().limit() + // Call 2: users lookup with .where().limit() + // Call 3: servers lookup (id, name, type) - awaited directly, no .where() or .limit() + if (txSelectCallCount === 3) { + // tx.select({ id, name, type }).from(servers) - awaited directly + return { + from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'MyServer', type: 'plex' }]), + }; + } + return { + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + })), + limit: vi.fn().mockResolvedValue([{ name: 'MyServer' }]), + })), + }; + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.accessToken).toBe('mock.jwt.token'); + expect(body.refreshToken).toBeDefined(); + expect(body.server.id).toBe(mockServerId); + expect(body.server.name).toBe('MyServer'); + expect(body.server.type).toBe('plex'); + expect(body.user.role).toBe('owner'); + }); + + it('rejects when rate limited', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(6); // Exceeds limit of 5 + mockRedis.ttl.mockResolvedValue(300); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(429); + expect(response.headers['retry-after']).toBe('300'); + }); + + it('rejects invalid token prefix', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + ...validPairPayload, + token: 'invalid_prefix_token', + }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('Invalid mobile token'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_valid', + // Missing required fields + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Invalid pairing request'); + }); + + it('rejects when max devices reached', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + // Mock device count check - 5 devices (at limit) + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { + from: vi.fn().mockResolvedValue([{ count: 5 }]), + } as never; + } else { + // No existing session for this device + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Maximum of 5 devices allowed'); + }); + + it('rejects expired token', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + // Mock transaction that throws TOKEN_EXPIRED + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + createMockToken({ expiresAt: new Date(Date.now() - 1000) }), + ]), + }), + })), + })), + })), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('This pairing token has expired'); + }); + + it('rejects already used token', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + // Mock transaction that throws TOKEN_ALREADY_USED + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + createMockToken({ usedAt: new Date() }), + ]), + }), + })), + })), + })), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('This pairing token has already been used'); + }); + + it('returns error when no owner account exists', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + + // Mock db.select() outside transaction: + // Call 1: count sessions (db.select().from()) + // Call 2: existing session check (db.select().from().where().limit()) + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + // Sessions count - returns { count: 0 } + return { + from: vi.fn().mockResolvedValue([{ count: 0 }]), + } as never; + } else { + // Existing session check - no existing session + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + // Mock transaction that returns NO_OWNER error + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 1) { + // Token lookup - valid token + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + }), + }), + }; + } else if (txSelectCallCount === 2) { + // Owner lookup - no owner found + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }; + } + return { from: vi.fn().mockResolvedValue([]) }; + }), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body.message).toBe('No owner account found'); + }); + + it('returns error when token is invalid', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + + // Mock db.select() outside transaction + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 0 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + // Mock transaction that throws INVALID_TOKEN error (no token found) + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + // Token lookup returns empty array + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }; + }), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('Invalid mobile token'); + }); + + it('returns generic error for unexpected transaction failures', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + + // Mock db.select() outside transaction + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 0 }]) } as never; + } else { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + // Mock transaction that throws an unexpected error + vi.mocked(db.transaction).mockRejectedValue(new Error('Database connection lost')); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body.message).toBe('Pairing failed. Please try again.'); + }); + + it('cleans up old refresh token when updating existing session', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + + const existingSessionId = randomUUID(); + const oldRefreshHash = 'old-refresh-token-hash-1234567890'; + + // First select: device count + // Second select: existing session check + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { from: vi.fn().mockResolvedValue([{ count: 1 }]) } as never; + } + // Existing session found + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ + id: existingSessionId, + refreshTokenHash: oldRefreshHash, + deviceName: 'Old Device', + platform: 'ios', + }]), + }), + }), + } as never; + }); + + const mockOwner = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockServerId = randomUUID(); + vi.mocked(db.transaction).mockImplementation(async (callback) => { + let txSelectCallCount = 0; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => { + txSelectCallCount++; + if (txSelectCallCount === 3) { + return { from: vi.fn().mockResolvedValue([{ id: mockServerId, name: 'Server', type: 'plex' }]) }; + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([createMockToken()]), + }), + limit: vi.fn().mockResolvedValue([mockOwner]), + }), + }), + }; + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(tx as never); + }); + + mockJwt.sign.mockReturnValue('mock.jwt.token'); + mockRedis.setex.mockResolvedValue('OK'); + mockRedis.del.mockResolvedValue(1); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: validPairPayload, + }); + + expect(response.statusCode).toBe(200); + // Verify old refresh token was deleted from Redis + expect(mockRedis.del).toHaveBeenCalledWith(`tracearr:mobile_refresh:${oldRefreshHash}`); + }); + }); + + describe('POST /mobile/refresh', () => { + it('refreshes mobile JWT with valid refresh token', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); // Rate limit OK + mockRedis.get.mockResolvedValue( + JSON.stringify({ userId: randomUUID(), deviceId: 'device-123' }) + ); + + const mockUser = { id: randomUUID(), username: 'owner', role: 'owner' }; + const mockSession = createMockSession(); + + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + // User query + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockUser]), + }), + }), + } as never; + } else if (selectCallCount === 2) { + // Session query + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockSession]), + }), + }), + } as never; + } else { + // Servers query + return { + from: vi.fn().mockResolvedValue([{ id: randomUUID() }]), + } as never; + } + }); + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + } as never); + + mockJwt.sign.mockReturnValue('new.jwt.token'); + mockRedis.del.mockResolvedValue(1); + mockRedis.setex.mockResolvedValue('OK'); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: { refreshToken: 'valid-refresh-token' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.accessToken).toBe('new.jwt.token'); + expect(body.refreshToken).toBeDefined(); + }); + + it('rejects when rate limited', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(31); // Exceeds limit of 30 + mockRedis.ttl.mockResolvedValue(600); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: { refreshToken: 'any-token' }, + }); + + expect(response.statusCode).toBe(429); + }); + + it('rejects invalid refresh token', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + mockRedis.get.mockResolvedValue(null); // Token not found in Redis + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: { refreshToken: 'invalid-token' }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('Invalid or expired refresh token'); + }); + + it('rejects when user no longer valid', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + mockRedis.get.mockResolvedValue( + JSON.stringify({ userId: randomUUID(), deviceId: 'device-123' }) + ); + mockRedis.del.mockResolvedValue(1); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), // User not found + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: { refreshToken: 'valid-token' }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('User no longer valid'); + }); + + it('rejects when session has been revoked', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + mockRedis.get.mockResolvedValue( + JSON.stringify({ userId: randomUUID(), deviceId: 'device-123' }) + ); + mockRedis.del.mockResolvedValue(1); + + const mockUser = { id: randomUUID(), username: 'owner', role: 'owner' }; + + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockUser]), + }), + }), + } as never; + } else { + // Session not found (revoked) + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: { refreshToken: 'valid-token' }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('Session has been revoked'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/refresh', + payload: {}, // Missing refreshToken + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Invalid refresh request'); + }); + }); + + describe('POST /mobile/push-token', () => { + it('registers push token for mobile user', async () => { + app = await buildTestApp(mobileUser); + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: randomUUID() }]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/push-token', + payload: { + expoPushToken: 'ExponentPushToken[abc123]', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.updatedSessions).toBe(1); + }); + + it('accepts device secret with push token', async () => { + app = await buildTestApp(mobileUser); + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: randomUUID() }]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/push-token', + payload: { + expoPushToken: 'ExponentPushToken[abc123]', + deviceSecret: 'a'.repeat(32), // 32 character secret + }, + }); + + expect(response.statusCode).toBe(200); + expect(db.update).toHaveBeenCalled(); + }); + + it('rejects invalid push token format', async () => { + app = await buildTestApp(mobileUser); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/push-token', + payload: { + expoPushToken: 'invalid-token-format', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Invalid push token format'); + }); + + it('rejects when deviceId missing from JWT', async () => { + // Create user without deviceId + const userWithoutDevice: AuthUser = { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + // No deviceId + }; + app = await buildTestApp(userWithoutDevice); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/push-token', + payload: { + expoPushToken: 'ExponentPushToken[abc123]', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('missing deviceId'); + }); + + it('returns 404 when session not found', async () => { + app = await buildTestApp(mobileUser); + + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), // No session found + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/push-token', + payload: { + expoPushToken: 'ExponentPushToken[abc123]', + }, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.message).toContain('No mobile session found'); + }); + }); + + // ============================================================================ + // Stream Termination Tests + // ============================================================================ + + // ============================================ + // Beta Mode Tests + // ============================================ + + describe('MOBILE_BETA_MODE', () => { + // Note: MOBILE_BETA_MODE is read at module load time from process.env + // These tests verify the behavior differences are properly implemented + // by testing the conditional paths in the code + + describe('when disabled (default)', () => { + it('rejects already used token', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + vi.mocked(db.select).mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }) as never); + + // Token with usedAt set should be rejected in normal mode + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + for: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + createMockToken({ usedAt: new Date() }), + ]), + }), + })), + })), + })), + }; + return callback(tx as never); + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-123', + platform: 'ios', + }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('This pairing token has already been used'); + }); + + it('enforces max device limit', async () => { + app = await buildTestApp(null); + + mockRedis.eval.mockResolvedValue(1); + + // Mock device count at limit (5) + let selectCallCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) { + return { + from: vi.fn().mockResolvedValue([{ count: 5 }]), + } as never; + } else { + // No existing session for this device + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never; + } + }); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair', + payload: { + token: 'trr_mob_validtokenvalue12345678901234567890', + deviceName: 'iPhone 15', + deviceId: 'device-123', + platform: 'ios', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Maximum of 5 devices allowed'); + }); + + it('token generation uses 15 minute expiry', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ mobileEnabled: true }]), + }), + } as never); + + mockRedis.eval.mockResolvedValue(1); + + let capturedExpiry: Date | null = null; + vi.mocked(db.transaction).mockImplementation(async (callback) => { + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + }), + }), + insert: vi.fn().mockImplementation(() => ({ + values: vi.fn().mockImplementation((values: { expiresAt: Date }) => { + capturedExpiry = values.expiresAt; + return Promise.resolve(undefined); + }), + })), + }; + return callback(tx as never); + }); + + const beforeRequest = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/mobile/pair-token', + }); + const afterRequest = Date.now(); + + expect(response.statusCode).toBe(200); + expect(capturedExpiry).not.toBeNull(); + + // Token should expire in ~15 minutes (with some tolerance) + const expiryMs = capturedExpiry!.getTime() - beforeRequest; + const expectedExpiryMs = 15 * 60 * 1000; + expect(expiryMs).toBeGreaterThanOrEqual(expectedExpiryMs - 1000); + expect(expiryMs).toBeLessThanOrEqual(expectedExpiryMs + (afterRequest - beforeRequest) + 1000); + }); + }); + + // Integration tests for beta mode would require module reset with env var set + // These are documented here for reference when running with MOBILE_BETA_MODE=true: + // + // describe('when enabled', () => { + // - Token expiry should be ~100 years (BETA_TOKEN_EXPIRY_YEARS) + // - Already-used tokens should still be accepted + // - Device limit should not be enforced (>5 devices allowed) + // - Server startup log should show beta mode warning + // }); + }); + + describe('POST /mobile/streams/:id/terminate', () => { + const serverId = randomUUID(); + const sessionId = randomUUID(); + + it('successfully terminates a session as owner', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + // Mock session lookup + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: sessionId, + serverId, + serverUserId: randomUUID(), + state: 'playing', + }, + ]), + }), + }), + } as never); + + // Mock termination service success + vi.mocked(terminateSession).mockResolvedValue({ + success: true, + terminationLogId: randomUUID(), + }); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: { + reason: 'Testing termination', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.terminationLogId).toBeDefined(); + expect(body.message).toBe('Stream termination command sent successfully'); + + // Verify terminateSession was called with correct args + expect(terminateSession).toHaveBeenCalledWith({ + sessionId, + trigger: 'manual', + triggeredByUserId: ownerMobileUser.userId, + reason: 'Testing termination', + }); + }); + + it('successfully terminates a session as admin', async () => { + const adminMobileUser = createMobileAdminUser(serverId); + app = await buildTestApp(adminMobileUser); + + // Mock session lookup + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: sessionId, + serverId, + serverUserId: randomUUID(), + state: 'playing', + }, + ]), + }), + }), + } as never); + + // Mock termination service success + vi.mocked(terminateSession).mockResolvedValue({ + success: true, + terminationLogId: randomUUID(), + }); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + }); + + it('returns 403 for viewer trying to terminate', async () => { + const viewerMobileUser = createMobileViewerUser(serverId); + app = await buildTestApp(viewerMobileUser); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toContain('Only administrators can terminate'); + }); + + it('returns 404 when session not found', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + // Mock session lookup - no session found + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.message).toContain('Session not found'); + }); + + it('returns 403 when user lacks server access', async () => { + const otherServerId = randomUUID(); + // Use admin user (not owner) so server access is checked + const adminMobileUser = createMobileAdminUser(otherServerId); // User has access to a different server + app = await buildTestApp(adminMobileUser); + + // Mock session lookup - session exists but on different server + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: sessionId, + serverId, // Session is on serverId, user has access to otherServerId + serverUserId: randomUUID(), + state: 'playing', + }, + ]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toContain('do not have access to this server'); + }); + + it('returns 409 when session already stopped', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + // Mock session lookup - session already stopped + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: sessionId, + serverId, + serverUserId: randomUUID(), + state: 'stopped', + }, + ]), + }), + }), + } as never); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.message).toContain('already ended'); + }); + + it('returns 500 when termination service fails', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + // Mock session lookup + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: sessionId, + serverId, + serverUserId: randomUUID(), + state: 'playing', + }, + ]), + }), + }), + } as never); + + // Mock termination service failure + vi.mocked(terminateSession).mockResolvedValue({ + success: false, + terminationLogId: randomUUID(), + error: 'Media server connection failed', + }); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: {}, + }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body.success).toBe(false); + expect(body.error).toBe('Media server connection failed'); + expect(body.terminationLogId).toBeDefined(); + }); + + it('returns 400 for invalid session ID format', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + const response = await app.inject({ + method: 'POST', + url: '/mobile/streams/not-a-uuid/terminate', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Invalid session ID'); + }); + + it('returns 400 for reason exceeding max length', async () => { + const ownerMobileUser = { + ...createMobileUser(), + serverIds: [serverId], + }; + app = await buildTestApp(ownerMobileUser); + + const response = await app.inject({ + method: 'POST', + url: `/mobile/streams/${sessionId}/terminate`, + payload: { + reason: 'a'.repeat(501), // Exceeds 500 char limit + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('Invalid request body'); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/notificationPreferences.test.ts b/apps/server/src/routes/__tests__/notificationPreferences.test.ts new file mode 100644 index 0000000..d67efd4 --- /dev/null +++ b/apps/server/src/routes/__tests__/notificationPreferences.test.ts @@ -0,0 +1,629 @@ +/** + * Notification Preferences routes tests + * + * Tests the API endpoints for mobile notification preferences: + * - GET /notifications/preferences - Get preferences for current device + * - PATCH /notifications/preferences - Update preferences for current device + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; + +// Define mobile auth user type +interface MobileAuthUser { + userId: string; + deviceId?: string; + role: 'owner' | 'guest'; +} + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, +})); + +// Mock the push rate limiter +vi.mock('../../services/pushRateLimiter.js', () => ({ + getPushRateLimiter: vi.fn(), +})); + +// Import mocked modules +import { db } from '../../db/client.js'; +import { getPushRateLimiter } from '../../services/pushRateLimiter.js'; +import { notificationPreferencesRoutes } from '../notificationPreferences.js'; + +// Helper to create DB chain mocks +function mockDbSelectLimit(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +function mockDbInsert(result: unknown[]) { + const chain = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + return chain; +} + +function mockDbUpdate() { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + return chain; +} + +async function buildTestApp(mobileUser: MobileAuthUser): Promise { + const app = Fastify({ logger: false }); + await app.register(sensible); + + // Mock requireMobile middleware + app.decorate('requireMobile', async (request: unknown) => { + (request as { user: MobileAuthUser }).user = mobileUser; + }); + + await app.register(notificationPreferencesRoutes, { prefix: '/notifications' }); + return app; +} + +const mobileSessionId = randomUUID(); +const deviceId = randomUUID(); + +const mobileUser: MobileAuthUser = { + userId: randomUUID(), + deviceId: deviceId, + role: 'owner', +}; + +const mockMobileSession = { + id: mobileSessionId, + deviceId: deviceId, + deviceName: 'Test iPhone', + expoPushToken: 'ExponentPushToken[xxx]', + lastSeenAt: new Date(), +}; + +const mockPrefsRow = { + id: randomUUID(), + mobileSessionId: mobileSessionId, + pushEnabled: true, + onViolationDetected: true, + onStreamStarted: false, + onStreamStopped: false, + onConcurrentStreams: true, + onNewDevice: true, + onTrustScoreChanged: false, + onServerDown: true, + onServerUp: true, + violationMinSeverity: 1, + violationRuleTypes: ['impossible_travel', 'concurrent_streams'], + maxPerMinute: 5, + maxPerHour: 50, + quietHoursEnabled: false, + quietHoursStart: null, + quietHoursEnd: null, + quietHoursTimezone: 'UTC', + quietHoursOverrideCritical: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('Notification Preferences Routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + describe('GET /notifications/preferences', () => { + it('returns existing preferences for mobile user with deviceId', async () => { + app = await buildTestApp(mobileUser); + + // Mock: find mobile session by deviceId, then find preferences + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] // Mobile session found + : [mockPrefsRow] // Preferences found + ), + }; + return chain as never; + }); + + // No rate limiter + vi.mocked(getPushRateLimiter).mockReturnValue(null); + + const response = await app.inject({ + method: 'GET', + url: '/notifications/preferences', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.pushEnabled).toBe(true); + expect(body.onViolationDetected).toBe(true); + expect(body.maxPerMinute).toBe(5); + expect(body.violationRuleTypes).toEqual(['impossible_travel', 'concurrent_streams']); + }); + + it('includes rate limit status when rate limiter is available', async () => { + app = await buildTestApp(mobileUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] + : [mockPrefsRow] + ), + }; + return chain as never; + }); + + // Mock rate limiter with status + const mockRateLimiter = { + getStatus: vi.fn().mockResolvedValue({ + remainingMinute: 3, + remainingHour: 45, + resetMinuteIn: 30, + resetHourIn: 1800, + }), + }; + vi.mocked(getPushRateLimiter).mockReturnValue(mockRateLimiter as never); + + const response = await app.inject({ + method: 'GET', + url: '/notifications/preferences', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.rateLimitStatus).toBeDefined(); + expect(body.rateLimitStatus.remainingMinute).toBe(3); + expect(body.rateLimitStatus.remainingHour).toBe(45); + }); + + it('creates default preferences if none exist', async () => { + app = await buildTestApp(mobileUser); + + const defaultPrefs = { + ...mockPrefsRow, + pushEnabled: true, + onViolationDetected: true, + onStreamStarted: false, + onStreamStopped: false, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] // Mobile session found + : [] // No preferences yet + ), + }; + return chain as never; + }); + + mockDbInsert([defaultPrefs]); + vi.mocked(getPushRateLimiter).mockReturnValue(null); + + const response = await app.inject({ + method: 'GET', + url: '/notifications/preferences', + }); + + expect(response.statusCode).toBe(200); + expect(db.insert).toHaveBeenCalled(); + }); + + it('falls back to user lookup when deviceId not provided', async () => { + const userWithoutDeviceId: MobileAuthUser = { + userId: randomUUID(), + // No deviceId + role: 'owner', + }; + app = await buildTestApp(userWithoutDeviceId); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] // Fallback finds session + : [mockPrefsRow] + ), + }; + return chain as never; + }); + + vi.mocked(getPushRateLimiter).mockReturnValue(null); + + const response = await app.inject({ + method: 'GET', + url: '/notifications/preferences', + }); + + expect(response.statusCode).toBe(200); + }); + + it('returns 404 when no mobile session found', async () => { + app = await buildTestApp(mobileUser); + + mockDbSelectLimit([]); // No mobile session + + const response = await app.inject({ + method: 'GET', + url: '/notifications/preferences', + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toContain('No mobile session'); + }); + }); + + describe('PATCH /notifications/preferences', () => { + it('updates preferences for mobile user', async () => { + app = await buildTestApp(mobileUser); + + const updatedPrefs = { + ...mockPrefsRow, + pushEnabled: false, + onStreamStarted: true, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] // Mobile session found + : selectCount === 2 + ? [mockPrefsRow] // Existing prefs found + : [updatedPrefs] // Updated prefs returned + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + pushEnabled: false, + onStreamStarted: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(db.update).toHaveBeenCalled(); + const body = response.json(); + expect(body.pushEnabled).toBe(false); + expect(body.onStreamStarted).toBe(true); + }); + + it('updates all notification event preferences', async () => { + app = await buildTestApp(mobileUser); + + const updatedPrefs = { + ...mockPrefsRow, + onViolationDetected: false, + onConcurrentStreams: false, + onNewDevice: false, + onTrustScoreChanged: true, + onServerDown: false, + onServerUp: false, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] + : selectCount === 2 + ? [mockPrefsRow] + : [updatedPrefs] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + onViolationDetected: false, + onConcurrentStreams: false, + onNewDevice: false, + onTrustScoreChanged: true, + onServerDown: false, + onServerUp: false, + }, + }); + + expect(response.statusCode).toBe(200); + }); + + it('updates rate limit settings', async () => { + app = await buildTestApp(mobileUser); + + const updatedPrefs = { + ...mockPrefsRow, + maxPerMinute: 10, + maxPerHour: 100, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] + : selectCount === 2 + ? [mockPrefsRow] + : [updatedPrefs] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + maxPerMinute: 10, + maxPerHour: 100, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.maxPerMinute).toBe(10); + expect(body.maxPerHour).toBe(100); + }); + + it('updates quiet hours settings', async () => { + app = await buildTestApp(mobileUser); + + const updatedPrefs = { + ...mockPrefsRow, + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + quietHoursTimezone: 'America/New_York', + quietHoursOverrideCritical: false, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] + : selectCount === 2 + ? [mockPrefsRow] + : [updatedPrefs] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + quietHoursTimezone: 'America/New_York', + quietHoursOverrideCritical: false, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.quietHoursEnabled).toBe(true); + expect(body.quietHoursStart).toBe('22:00'); + expect(body.quietHoursEnd).toBe('07:00'); + }); + + it('updates violation filter settings', async () => { + app = await buildTestApp(mobileUser); + + const updatedPrefs = { + ...mockPrefsRow, + violationMinSeverity: 2, + violationRuleTypes: ['geo_restriction'], + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] + : selectCount === 2 + ? [mockPrefsRow] + : [updatedPrefs] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + violationMinSeverity: 2, + violationRuleTypes: ['geo_restriction'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.violationMinSeverity).toBe(2); + expect(body.violationRuleTypes).toEqual(['geo_restriction']); + }); + + it('creates preferences if they do not exist', async () => { + app = await buildTestApp(mobileUser); + + const newPrefs = { + ...mockPrefsRow, + pushEnabled: false, + }; + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ id: mockMobileSession.id }] // Mobile session found + : selectCount === 2 + ? [] // No existing prefs + : [newPrefs] // After update + ), + }; + return chain as never; + }); + + mockDbInsert([mockPrefsRow]); // Insert creates defaults + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + pushEnabled: false, + }, + }); + + expect(response.statusCode).toBe(200); + expect(db.insert).toHaveBeenCalled(); + }); + + it('returns 404 when no mobile session found', async () => { + app = await buildTestApp(mobileUser); + + mockDbSelectLimit([]); // No mobile session + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + pushEnabled: true, + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toContain('No mobile session'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(mobileUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + violationMinSeverity: 5, // Invalid: max is 3 + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects invalid quiet hours format', async () => { + app = await buildTestApp(mobileUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + quietHoursStart: '9:00', // Invalid format - should be HH:MM + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('rejects maxPerMinute outside valid range', async () => { + app = await buildTestApp(mobileUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/notifications/preferences', + payload: { + maxPerMinute: 100, // Max is 60 + }, + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/rules.test.ts b/apps/server/src/routes/__tests__/rules.test.ts new file mode 100644 index 0000000..d7fda3e --- /dev/null +++ b/apps/server/src/routes/__tests__/rules.test.ts @@ -0,0 +1,646 @@ +/** + * Rule routes integration tests + * + * Tests the API endpoints for rule CRUD operations: + * - GET /rules - List all rules + * - POST /rules - Create a new rule + * - GET /rules/:id - Get a specific rule + * - PATCH /rules/:id - Update a rule + * - DELETE /rules/:id - Delete a rule + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser, Rule } from '@tracearr/shared'; + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +// Import the mocked db and the routes +import { db } from '../../db/client.js'; +import { ruleRoutes } from '../rules.js'; + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers (badRequest, notFound, etc.) + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: any) => { + request.user = authUser; + }); + + // Register routes + await app.register(ruleRoutes, { prefix: '/rules' }); + + return app; +} + +/** + * Create a mock rule object + */ +function createTestRule(overrides: Partial = {}): Rule { + return { + id: overrides.id ?? randomUUID(), + name: overrides.name ?? 'Test Rule', + type: overrides.type ?? 'concurrent_streams', + params: overrides.params ?? { maxStreams: 3 }, + serverUserId: overrides.serverUserId ?? null, + isActive: overrides.isActive ?? true, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + }; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +describe('Rule Routes', () => { + let app: FastifyInstance; + let mockDb: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockDb = db as any; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /rules', () => { + it('should return list of rules for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const serverId = ownerUser.serverIds[0]; // Use owner's server + const testRules = [ + createTestRule({ name: 'Rule 1' }), // Global rule + createTestRule({ name: 'Rule 2', serverUserId: randomUUID() }), // User-specific rule + ]; + + // Mock the database chain (2 leftJoins: serverUsers and servers) + // Rule 1 is global (no serverUserId), Rule 2 is user-specific with serverId + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([ + { ...testRules[0], username: null, serverId: null, serverName: null }, + { ...testRules[1], username: 'testuser', serverId, serverName: 'Test Server' }, + ]), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: '/rules', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(2); + }); + + it('should filter user-specific rules for non-owners', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const globalRule = createTestRule({ name: 'Global Rule', serverUserId: null }); + const userRule = createTestRule({ name: 'User Rule', serverUserId: randomUUID() }); + + // Mock the database chain (2 leftJoins: serverUsers and servers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([ + { ...globalRule, username: null, serverId: null, serverName: null }, + { ...userRule, username: 'someone', serverId: randomUUID(), serverName: 'Test Server' }, + ]), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: '/rules', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + // Guest should only see global rules + expect(body.data).toHaveLength(1); + expect(body.data[0].serverUserId).toBeNull(); + }); + }); + + describe('POST /rules', () => { + it('should create a rule for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const newRule = createTestRule({ + name: 'New Rule', + type: 'impossible_travel', + params: { maxSpeedKmh: 500 }, + }); + + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([newRule]), + }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + name: 'New Rule', + type: 'impossible_travel', + params: { maxSpeedKmh: 500 }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.name).toBe('New Rule'); + expect(body.type).toBe('impossible_travel'); + }); + + it('should reject rule creation for non-owner', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + name: 'New Rule', + type: 'concurrent_streams', + params: { maxStreams: 3 }, + }, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should reject invalid request body', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + // Missing required fields + name: '', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject invalid rule type', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + name: 'Test Rule', + type: 'invalid_type', + params: {}, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('should verify serverUserId exists when provided', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const serverUserId = randomUUID(); + + // Server user not found + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + name: 'User Rule', + type: 'concurrent_streams', + params: { maxStreams: 3 }, + serverUserId, + }, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should create rule with valid serverUserId', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const serverUserId = randomUUID(); + const newRule = createTestRule({ serverUserId }); + + // Server user exists + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: serverUserId }]), + }), + }), + }); + + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([newRule]), + }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/rules', + payload: { + name: 'User Rule', + type: 'concurrent_streams', + params: { maxStreams: 3 }, + serverUserId, + }, + }); + + expect(response.statusCode).toBe(201); + }); + }); + + describe('GET /rules/:id', () => { + it('should return a specific rule', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const testRule = createTestRule({ id: ruleId }); + + // Mock rule query (2 leftJoins: serverUsers and servers) + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ ...testRule, username: null, serverId: null, serverName: null }]), + }), + }), + }), + }), + }); + + // Mock violation count query + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 5 }]), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/rules/${ruleId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(ruleId); + expect(body.violationCount).toBe(5); + }); + + it('should return 404 for non-existent rule', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + // Mock rule query (2 leftJoins: serverUsers and servers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/rules/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should reject invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/rules/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('PATCH /rules/:id', () => { + it('should update rule for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const existingRule = createTestRule({ id: ruleId, name: 'Old Name' }); + const updatedRule = { ...existingRule, name: 'New Name' }; + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ ...existingRule, serverId: null }]), + }), + }), + }), + }); + + // Update + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedRule]), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/rules/${ruleId}`, + payload: { + name: 'New Name', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.name).toBe('New Name'); + }); + + it('should reject update for non-owner', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const response = await app.inject({ + method: 'PATCH', + url: `/rules/${randomUUID()}`, + payload: { + name: 'New Name', + }, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should return 404 for non-existent rule', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/rules/${randomUUID()}`, + payload: { + name: 'New Name', + }, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should update isActive field', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const existingRule = createTestRule({ id: ruleId, isActive: true }); + const updatedRule = { ...existingRule, isActive: false }; + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ ...existingRule, serverId: null }]), + }), + }), + }), + }); + + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedRule]), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/rules/${ruleId}`, + payload: { + isActive: false, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.isActive).toBe(false); + }); + + it('should update params field', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const existingRule = createTestRule({ id: ruleId }); + const updatedRule = { ...existingRule, params: { maxStreams: 5 } }; + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ ...existingRule, serverId: null }]), + }), + }), + }), + }); + + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedRule]), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/rules/${ruleId}`, + payload: { + params: { maxStreams: 5 }, + }, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + describe('DELETE /rules/:id', () => { + it('should delete rule for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const existingRule = createTestRule({ id: ruleId }); + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ ...existingRule, serverId: null }]), + }), + }), + }), + }); + + // Delete + mockDb.delete.mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/rules/${ruleId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + }); + + it('should reject delete for non-owner', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const response = await app.inject({ + method: 'DELETE', + url: `/rules/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should return 404 for non-existent rule', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + // Rule exists check (1 leftJoin to serverUsers) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/rules/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should reject invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/rules/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/servers.test.ts b/apps/server/src/routes/__tests__/servers.test.ts new file mode 100644 index 0000000..302503e --- /dev/null +++ b/apps/server/src/routes/__tests__/servers.test.ts @@ -0,0 +1,767 @@ +/** + * Server routes tests + * + * Tests the API endpoints for server management: + * - GET /servers - List connected servers + * - POST /servers - Add a new server + * - DELETE /servers/:id - Remove a server + * - POST /servers/:id/sync - Force sync + * - GET /servers/:id/image/* - Proxy images + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock dependencies before imports +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('../../utils/crypto.js', () => ({ + encrypt: vi.fn((token: string) => `encrypted_${token}`), + decrypt: vi.fn((token: string) => token.replace('encrypted_', '')), +})); + +vi.mock('../../services/mediaServer/index.js', () => ({ + PlexClient: { + verifyServerAdmin: vi.fn(), + }, + JellyfinClient: { + verifyServerAdmin: vi.fn(), + }, + EmbyClient: { + verifyServerAdmin: vi.fn(), + }, +})); + +vi.mock('../../services/sync.js', () => ({ + syncServer: vi.fn(), +})); + +// Import mocked modules +import { db } from '../../db/client.js'; +import { PlexClient, JellyfinClient, EmbyClient } from '../../services/mediaServer/index.js'; +import { syncServer } from '../../services/sync.js'; +import { serverRoutes } from '../servers.js'; + +// Mock global fetch for image proxy tests +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Helper to create DB chain mocks +// For queries that end with .where() (no limit) +function mockDbSelectWhere(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +// For queries that end with .limit() +function mockDbSelectLimit(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +function mockDbInsert(result: unknown[]) { + const chain = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + return chain; +} + +function mockDbDelete() { + const chain = { + where: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.delete).mockReturnValue(chain as never); + return chain; +} + +function mockDbUpdate() { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + return chain; +} + +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + await app.register(sensible); + + // Mock authenticate + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + // Mock jwtVerify for image routes + app.decorateRequest('jwtVerify', async function (this: { user: AuthUser }) { + this.user = authUser; + }); + + await app.register(serverRoutes, { prefix: '/servers' }); + return app; +} + +const ownerUser: AuthUser = { + userId: randomUUID(), + username: 'admin', + role: 'owner', + serverIds: [], +}; + +const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], +}; + +const mockServer = { + id: randomUUID(), + name: 'Test Plex Server', + type: 'plex' as const, + url: 'http://localhost:32400', + token: 'encrypted_test-token', + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('Server Routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + describe('GET /servers', () => { + it('returns all servers for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectWhere([ + { id: mockServer.id, name: mockServer.name, type: mockServer.type, url: mockServer.url, createdAt: mockServer.createdAt, updatedAt: mockServer.updatedAt }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].name).toBe('Test Plex Server'); + // Should not include token + expect(body.data[0].token).toBeUndefined(); + }); + + it('returns only authorized servers for guest', async () => { + const guestServerId = randomUUID(); + const guestWithServer: AuthUser = { + ...viewerUser, + serverIds: [guestServerId], + }; + app = await buildTestApp(guestWithServer); + + mockDbSelectWhere([ + { id: guestServerId, name: 'Guest Server', type: 'jellyfin', url: 'http://localhost:8096', createdAt: new Date(), updatedAt: new Date() }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toBe(guestServerId); + }); + + it('returns empty array when guest has no server access', async () => { + const guestNoAccess: AuthUser = { + ...viewerUser, + serverIds: [], + }; + app = await buildTestApp(guestNoAccess); + + mockDbSelectWhere([]); + + const response = await app.inject({ + method: 'GET', + url: '/servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.data).toHaveLength(0); + }); + }); + + describe('POST /servers', () => { + beforeEach(() => { + vi.mocked(PlexClient.verifyServerAdmin).mockResolvedValue(true); + vi.mocked(JellyfinClient.verifyServerAdmin).mockResolvedValue(true); + vi.mocked(EmbyClient.verifyServerAdmin).mockResolvedValue(true); + vi.mocked(syncServer).mockResolvedValue({ usersAdded: 5, usersUpdated: 0, librariesSynced: 3, errors: [] }); + }); + + it('creates a new Plex server for owner', async () => { + app = await buildTestApp(ownerUser); + + // No existing server + mockDbSelectLimit([]); + + const newServer = { + id: randomUUID(), + name: 'New Plex', + type: 'plex', + url: 'http://plex.local:32400', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockDbInsert([newServer]); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'New Plex', + type: 'plex', + url: 'http://plex.local:32400', + token: 'my-plex-token', + }, + }); + + expect(response.statusCode).toBe(201); + expect(PlexClient.verifyServerAdmin).toHaveBeenCalledWith('my-plex-token', 'http://plex.local:32400'); + const body = response.json(); + expect(body.name).toBe('New Plex'); + expect(body.type).toBe('plex'); + }); + + it('creates a new Jellyfin server for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const newServer = { + id: randomUUID(), + name: 'New Jellyfin', + type: 'jellyfin', + url: 'http://jellyfin.local:8096', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockDbInsert([newServer]); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'New Jellyfin', + type: 'jellyfin', + url: 'http://jellyfin.local:8096', + token: 'my-jellyfin-token', + }, + }); + + expect(response.statusCode).toBe(201); + expect(JellyfinClient.verifyServerAdmin).toHaveBeenCalledWith('my-jellyfin-token', 'http://jellyfin.local:8096'); + }); + + it('creates a new Emby server for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const newServer = { + id: randomUUID(), + name: 'New Emby', + type: 'emby', + url: 'http://emby.local:8096', + createdAt: new Date(), + updatedAt: new Date(), + }; + mockDbInsert([newServer]); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'New Emby', + type: 'emby', + url: 'http://emby.local:8096', + token: 'my-emby-token', + }, + }); + + expect(response.statusCode).toBe(201); + expect(EmbyClient.verifyServerAdmin).toHaveBeenCalledWith('my-emby-token', 'http://emby.local:8096'); + }); + + it('rejects guest creating server', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'Guest Server', + type: 'plex', + url: 'http://guest.local:32400', + token: 'guest-token', + }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().message).toContain('Only server owners'); + }); + + it('rejects duplicate server URL', async () => { + app = await buildTestApp(ownerUser); + + // Existing server with same URL + mockDbSelectLimit([mockServer]); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'Duplicate', + type: 'plex', + url: mockServer.url, + token: 'test-token', + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().message).toContain('already exists'); + }); + + it('rejects non-admin token', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + vi.mocked(PlexClient.verifyServerAdmin).mockResolvedValue(false); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'Non-Admin', + type: 'plex', + url: 'http://nonadmin.local:32400', + token: 'non-admin-token', + }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().message).toContain('admin access'); + }); + + it('handles connection error to media server', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + vi.mocked(PlexClient.verifyServerAdmin).mockRejectedValue(new Error('Connection refused')); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: 'Unreachable', + type: 'plex', + url: 'http://unreachable.local:32400', + token: 'test-token', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('Failed to connect'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'POST', + url: '/servers', + payload: { + name: '', // Invalid: empty name + type: 'invalid-type', + url: 'not-a-url', + }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('DELETE /servers/:id', () => { + it('deletes server for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + mockDbDelete(); + + const response = await app.inject({ + method: 'DELETE', + url: `/servers/${mockServer.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().success).toBe(true); + }); + + it('rejects guest deleting server', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'DELETE', + url: `/servers/${mockServer.id}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('returns 404 for non-existent server', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const response = await app.inject({ + method: 'DELETE', + url: `/servers/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('returns 400 for invalid UUID', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/servers/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('POST /servers/:id/sync', () => { + beforeEach(() => { + vi.mocked(syncServer).mockResolvedValue({ + usersAdded: 3, + usersUpdated: 2, + librariesSynced: 5, + errors: [], + }); + }); + + it('syncs server for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + mockDbUpdate(); + + const response = await app.inject({ + method: 'POST', + url: `/servers/${mockServer.id}/sync`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + expect(body.usersAdded).toBe(3); + expect(body.usersUpdated).toBe(2); + expect(body.librariesSynced).toBe(5); + expect(body.errors).toEqual([]); + expect(syncServer).toHaveBeenCalledWith(mockServer.id, { syncUsers: true, syncLibraries: true }); + }); + + it('returns errors when sync has issues', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(syncServer).mockResolvedValue({ + usersAdded: 1, + usersUpdated: 0, + librariesSynced: 0, + errors: ['Failed to fetch library 1', 'User sync timeout'], + }); + + mockDbSelectLimit([mockServer]); + mockDbUpdate(); + + const response = await app.inject({ + method: 'POST', + url: `/servers/${mockServer.id}/sync`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(false); + expect(body.errors).toHaveLength(2); + }); + + it('rejects guest syncing server', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: `/servers/${mockServer.id}/sync`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('returns 404 for non-existent server', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const response = await app.inject({ + method: 'POST', + url: `/servers/${randomUUID()}/sync`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('handles sync service error', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + vi.mocked(syncServer).mockRejectedValue(new Error('Sync failed')); + + const response = await app.inject({ + method: 'POST', + url: `/servers/${mockServer.id}/sync`, + }); + + expect(response.statusCode).toBe(500); + }); + }); + + describe('GET /servers/:id/image/*', () => { + it('proxies Plex image with token in URL', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + + const imageBuffer = Buffer.from('fake-image-data'); + mockFetch.mockResolvedValue({ + ok: true, + headers: new Map([['content-type', 'image/jpeg']]), + arrayBuffer: () => Promise.resolve(imageBuffer), + }); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${mockServer.id}/image/library/metadata/123/thumb/456`, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toBe('image/jpeg'); + expect(response.headers['cache-control']).toContain('max-age=86400'); + + // Verify fetch was called with correct URL including Plex token + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('X-Plex-Token='), + expect.any(Object) + ); + }); + + it('proxies Jellyfin image with auth header', async () => { + const jellyfinServer = { + ...mockServer, + type: 'jellyfin' as const, + url: 'http://localhost:8096', + }; + + app = await buildTestApp(ownerUser); + mockDbSelectLimit([jellyfinServer]); + + const imageBuffer = Buffer.from('fake-image-data'); + mockFetch.mockResolvedValue({ + ok: true, + headers: new Map([['content-type', 'image/png']]), + arrayBuffer: () => Promise.resolve(imageBuffer), + }); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${jellyfinServer.id}/image/Items/abc/Images/Primary`, + }); + + expect(response.statusCode).toBe(200); + + // Verify fetch was called with X-Emby-Authorization header + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Emby-Authorization': expect.stringContaining('MediaBrowser'), + }), + }) + ); + }); + + it('accepts auth via query param for img tags', async () => { + // Create app with custom jwtVerify that reads from query + const customApp = Fastify({ logger: false }); + await customApp.register(sensible); + + customApp.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = ownerUser; + }); + + customApp.decorateRequest('jwtVerify', async function (this: { user: AuthUser; headers: { authorization?: string } }) { + // Simulate JWT verification - if header exists, it's valid + if (this.headers.authorization) { + this.user = ownerUser; + } else { + throw new Error('Missing token'); + } + }); + + await customApp.register(serverRoutes, { prefix: '/servers' }); + + mockDbSelectLimit([mockServer]); + mockFetch.mockResolvedValue({ + ok: true, + headers: new Map([['content-type', 'image/jpeg']]), + arrayBuffer: () => Promise.resolve(Buffer.from('image')), + }); + + const response = await customApp.inject({ + method: 'GET', + url: `/servers/${mockServer.id}/image/thumb.jpg?token=valid-jwt-token`, + }); + + expect(response.statusCode).toBe(200); + await customApp.close(); + }); + + it('returns 404 for non-existent server', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${randomUUID()}/image/thumb.jpg`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('returns 404 when upstream image not found', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + }); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${mockServer.id}/image/nonexistent.jpg`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('handles fetch error gracefully', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockServer]); + mockFetch.mockRejectedValue(new Error('Network error')); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${mockServer.id}/image/thumb.jpg`, + }); + + expect(response.statusCode).toBe(500); + }); + + it('returns 400 when image path is missing', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${mockServer.id}/image/`, + }); + + // Wildcard route with empty path + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /servers/:id/statistics', () => { + it('returns 404 for non-existent server', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([]); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${randomUUID()}/statistics`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('returns 400 for non-Plex server', async () => { + const jellyfinServer = { + ...mockServer, + type: 'jellyfin' as const, + }; + + app = await buildTestApp(ownerUser); + mockDbSelectLimit([jellyfinServer]); + + const response = await app.inject({ + method: 'GET', + url: `/servers/${jellyfinServer.id}/statistics`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('only available for Plex'); + }); + + it('returns 400 for invalid server ID', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/servers/not-a-uuid/statistics', + }); + + expect(response.statusCode).toBe(400); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/sessions.test.ts b/apps/server/src/routes/__tests__/sessions.test.ts new file mode 100644 index 0000000..f360c86 --- /dev/null +++ b/apps/server/src/routes/__tests__/sessions.test.ts @@ -0,0 +1,628 @@ +/** + * Session routes tests + * + * Tests the API endpoints for session queries: + * - GET /sessions - List historical sessions with filters + * - GET /sessions/active - Get currently active streams + * - GET /sessions/:id - Get a specific session + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser, ActiveSession } from '@tracearr/shared'; + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + execute: vi.fn(), + }, +})); + +// Mock cache service - need to provide getAllActiveSessions for /active endpoint +const mockGetAllActiveSessions = vi.fn().mockResolvedValue([]); +vi.mock('../../services/cache.js', () => ({ + getCacheService: vi.fn(() => ({ + getAllActiveSessions: mockGetAllActiveSessions, + getSessionById: vi.fn().mockResolvedValue(null), + })), +})); + +// Import the mocked db and the routes +import { db } from '../../db/client.js'; +import { sessionRoutes } from '../sessions.js'; + +/** + * Build a test Fastify instance with mocked auth and redis + */ +async function buildTestApp( + authUser: AuthUser, + redisMock?: { get: ReturnType } +): Promise { + const app = Fastify({ logger: false }); + + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: any) => { + request.user = authUser; + }); + + // Mock Redis (cast to never for test mock) + app.decorate('redis', (redisMock ?? { get: vi.fn().mockResolvedValue(null) }) as never); + + await app.register(sessionRoutes, { prefix: '/sessions' }); + + return app; +} + +function createOwnerUser(serverIds?: string[]): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: serverIds ?? [randomUUID()], + }; +} + +function createViewerUser(serverIds?: string[]): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: serverIds ?? [randomUUID()], + }; +} + +function createActiveSession(overrides: Partial = {}): ActiveSession { + const serverId = overrides.serverId ?? randomUUID(); + return { + id: overrides.id ?? randomUUID(), + sessionKey: overrides.sessionKey ?? 'session-123', + serverId, + serverUserId: overrides.serverUserId ?? randomUUID(), + state: overrides.state ?? 'playing', + mediaType: overrides.mediaType ?? 'movie', + mediaTitle: overrides.mediaTitle ?? 'Test Movie', + grandparentTitle: overrides.grandparentTitle ?? null, + seasonNumber: overrides.seasonNumber ?? null, + episodeNumber: overrides.episodeNumber ?? null, + year: overrides.year ?? 2024, + thumbPath: overrides.thumbPath ?? '/library/metadata/123/thumb', + ratingKey: overrides.ratingKey ?? 'media-123', + externalSessionId: overrides.externalSessionId ?? null, + startedAt: overrides.startedAt ?? new Date(), + stoppedAt: overrides.stoppedAt ?? null, + durationMs: overrides.durationMs ?? 0, + progressMs: overrides.progressMs ?? 0, + totalDurationMs: overrides.totalDurationMs ?? 7200000, + lastPausedAt: overrides.lastPausedAt ?? null, + pausedDurationMs: overrides.pausedDurationMs ?? 0, + referenceId: overrides.referenceId ?? null, + watched: overrides.watched ?? false, + ipAddress: overrides.ipAddress ?? '192.168.1.100', + geoCity: overrides.geoCity ?? 'New York', + geoRegion: overrides.geoRegion ?? 'NY', + geoCountry: overrides.geoCountry ?? 'US', + geoLat: overrides.geoLat ?? 40.7128, + geoLon: overrides.geoLon ?? -74.006, + playerName: overrides.playerName ?? 'Chrome', + deviceId: overrides.deviceId ?? 'device-123', + product: overrides.product ?? 'Plex Web', + device: overrides.device ?? 'Chrome', + platform: overrides.platform ?? 'Chrome', + quality: overrides.quality ?? '1080p', + isTranscode: overrides.isTranscode ?? false, + bitrate: overrides.bitrate ?? 20000, + user: overrides.user ?? { + id: randomUUID(), + username: 'testuser', + thumbUrl: null, + identityName: null, + }, + server: overrides.server ?? { + id: serverId, + name: 'Test Server', + type: 'plex', + }, + }; +} + +describe('Session Routes', () => { + let app: FastifyInstance; + let mockDb: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockDb = db as any; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /sessions', () => { + it('should return paginated sessions for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const mockSessionRows = [ + { + id: randomUUID(), + started_at: new Date(), + stopped_at: new Date(), + duration_ms: '3600000', + paused_duration_ms: '0', + progress_ms: 3600000, + segment_count: '1', + watched: true, + state: 'stopped', + server_id: ownerUser.serverIds[0], + server_name: 'Test Server', + server_type: 'plex', + server_user_id: randomUUID(), + username: 'testuser', + user_thumb: null, + session_key: 'session-1', + media_type: 'movie', + media_title: 'Test Movie', + grandparent_title: null, + season_number: null, + episode_number: null, + year: 2024, + thumb_path: '/thumb', + reference_id: null, + ip_address: '192.168.1.1', + geo_city: 'NYC', + geo_region: 'NY', + geo_country: 'US', + geo_lat: 40.7, + geo_lon: -74.0, + player_name: 'Chrome', + device_id: 'dev-1', + product: 'Plex Web', + device: 'Chrome', + platform: 'Chrome', + quality: '1080p', + is_transcode: false, + bitrate: 20000, + }, + ]; + + // Mock the main query + mockDb.execute.mockResolvedValueOnce({ rows: mockSessionRows }); + // Mock the count query + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/sessions', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.page).toBe(1); + expect(body.total).toBe(1); + }); + + it('should filter by serverUserId', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + const serverUserId = randomUUID(); + const response = await app.inject({ + method: 'GET', + url: `/sessions?serverUserId=${serverUserId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(0); + }); + + it('should filter by mediaType', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/sessions?mediaType=movie', + }); + + expect(response.statusCode).toBe(200); + }); + + it('should filter by date range', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/sessions?startDate=2024-01-01&endDate=2024-12-31', + }); + + expect(response.statusCode).toBe(200); + }); + + it('should handle pagination', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 100 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/sessions?page=2&pageSize=25', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.page).toBe(2); + expect(body.pageSize).toBe(25); + expect(body.totalPages).toBe(4); + }); + + it('should reject invalid query parameters', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions?page=-1', + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('GET /sessions/active', () => { + it('should return active sessions from cache', async () => { + const serverId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + const activeSessions = [createActiveSession({ serverId })]; + + // Mock the cache service response + mockGetAllActiveSessions.mockResolvedValueOnce(activeSessions); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions/active', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(mockGetAllActiveSessions).toHaveBeenCalled(); + }); + + it('should return empty array when cache is empty', async () => { + const ownerUser = createOwnerUser(); + + // Mock empty cache + mockGetAllActiveSessions.mockResolvedValueOnce([]); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions/active', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(0); + }); + + it('should filter sessions by user serverIds', async () => { + const serverId1 = randomUUID(); + const serverId2 = randomUUID(); + const viewerUser = createViewerUser([serverId1]); + + const activeSessions = [ + createActiveSession({ serverId: serverId1 }), + createActiveSession({ serverId: serverId2 }), + ]; + + // Mock the cache service response + mockGetAllActiveSessions.mockResolvedValueOnce(activeSessions); + + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions/active', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].serverId).toBe(serverId1); + }); + + it('should handle invalid JSON in cache', async () => { + const ownerUser = createOwnerUser(); + + // getAllActiveSessions handles parsing internally, so this just tests empty + mockGetAllActiveSessions.mockResolvedValueOnce([]); + + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions/active', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(0); + }); + }); + + describe('GET /sessions/:id', () => { + it('should return session from cache if active', async () => { + const serverId = randomUUID(); + const sessionId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + + const activeSession = createActiveSession({ id: sessionId, serverId }); + + const redisMock = { + get: vi.fn().mockResolvedValue(JSON.stringify(activeSession)), + }; + + app = await buildTestApp(ownerUser, redisMock); + + const response = await app.inject({ + method: 'GET', + url: `/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(sessionId); + expect(body.username).toBe(activeSession.user.username); + expect(body.serverName).toBe(activeSession.server.name); + }); + + it('should return session from database if not in cache', async () => { + const serverId = randomUUID(); + const sessionId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + + const redisMock = { + get: vi.fn().mockResolvedValue(null), + }; + + app = await buildTestApp(ownerUser, redisMock); + + const dbSession = { + id: sessionId, + serverId, + serverName: 'Test Server', + serverType: 'plex', + serverUserId: randomUUID(), + username: 'testuser', + userThumb: null, + sessionKey: 'session-1', + state: 'stopped', + mediaType: 'movie', + mediaTitle: 'Test Movie', + grandparentTitle: null, + seasonNumber: null, + episodeNumber: null, + year: 2024, + thumbPath: '/thumb', + startedAt: new Date(), + stoppedAt: new Date(), + durationMs: 3600000, + progressMs: 3600000, + totalDurationMs: 7200000, + lastPausedAt: null, + pausedDurationMs: 0, + referenceId: null, + watched: true, + ipAddress: '192.168.1.1', + geoCity: 'NYC', + geoRegion: 'NY', + geoCountry: 'US', + geoLat: 40.7, + geoLon: -74.0, + playerName: 'Chrome', + deviceId: 'dev-1', + product: 'Plex Web', + device: 'Chrome', + platform: 'Chrome', + quality: '1080p', + isTranscode: false, + bitrate: 20000, + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([dbSession]), + }), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(sessionId); + }); + + it('should return 404 for non-existent session', async () => { + const ownerUser = createOwnerUser(); + const redisMock = { + get: vi.fn().mockResolvedValue(null), + }; + + app = await buildTestApp(ownerUser, redisMock); + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/sessions/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should return 400 for invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/sessions/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return 403 when user lacks access to session server', async () => { + const serverId = randomUUID(); + const sessionId = randomUUID(); + const differentServerId = randomUUID(); + const viewerUser = createViewerUser([differentServerId]); + + const redisMock = { + get: vi.fn().mockResolvedValue(null), + }; + + app = await buildTestApp(viewerUser, redisMock); + + const dbSession = { + id: sessionId, + serverId, + serverName: 'Test Server', + serverType: 'plex', + serverUserId: randomUUID(), + username: 'testuser', + userThumb: null, + sessionKey: 'session-1', + state: 'stopped', + mediaType: 'movie', + mediaTitle: 'Test Movie', + grandparentTitle: null, + seasonNumber: null, + episodeNumber: null, + year: 2024, + thumbPath: '/thumb', + startedAt: new Date(), + stoppedAt: new Date(), + durationMs: 3600000, + progressMs: 3600000, + totalDurationMs: 7200000, + lastPausedAt: null, + pausedDurationMs: 0, + referenceId: null, + watched: true, + ipAddress: '192.168.1.1', + geoCity: 'NYC', + geoRegion: 'NY', + geoCountry: 'US', + geoLat: 40.7, + geoLon: -74.0, + playerName: 'Chrome', + deviceId: 'dev-1', + product: 'Plex Web', + device: 'Chrome', + platform: 'Chrome', + quality: '1080p', + isTranscode: false, + bitrate: 20000, + }; + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([dbSession]), + }), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should deny access to cached session from wrong server', async () => { + const serverId = randomUUID(); + const sessionId = randomUUID(); + const differentServerId = randomUUID(); + const viewerUser = createViewerUser([differentServerId]); + + const activeSession = createActiveSession({ id: sessionId, serverId }); + + const redisMock = { + get: vi.fn().mockResolvedValue(JSON.stringify(activeSession)), + }; + + app = await buildTestApp(viewerUser, redisMock); + + // Should fall through to DB since server access denied + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/sessions/${sessionId}`, + }); + + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/settings.test.ts b/apps/server/src/routes/__tests__/settings.test.ts new file mode 100644 index 0000000..315e39b --- /dev/null +++ b/apps/server/src/routes/__tests__/settings.test.ts @@ -0,0 +1,698 @@ +/** + * Settings routes tests + * + * Tests the API endpoints for application settings: + * - GET /settings - Get application settings (owner only) + * - PATCH /settings - Update application settings (owner only) + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, +})); + +// Import mocked modules +import { db } from '../../db/client.js'; +import { settingsRoutes } from '../settings.js'; + +// Helper to create DB chain mocks +function mockDbSelectLimit(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +function mockDbInsert(result: unknown[]) { + const chain = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + return chain; +} + +function mockDbUpdate() { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + return chain; +} + +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + await app.register(sensible); + + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + await app.register(settingsRoutes, { prefix: '/settings' }); + return app; +} + +const ownerUser: AuthUser = { + userId: randomUUID(), + username: 'admin', + role: 'owner', + serverIds: [], +}; + +const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [], +}; + +const mockSettingsRow = { + id: 1, + allowGuestAccess: false, + discordWebhookUrl: 'https://discord.com/api/webhooks/123', + customWebhookUrl: 'https://example.com/webhook', + webhookFormat: 'json' as const, + ntfyTopic: null, + pollerEnabled: true, + pollerIntervalMs: 15000, + tautulliUrl: 'http://localhost:8181', + tautulliApiKey: 'secret-api-key', + externalUrl: 'https://tracearr.example.com', + basePath: '/app', + trustProxy: true, + mobileEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('Settings Routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + describe('GET /settings', () => { + it('returns settings for owner', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockSettingsRow]); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.allowGuestAccess).toBe(false); + expect(body.discordWebhookUrl).toBe('https://discord.com/api/webhooks/123'); + expect(body.pollerEnabled).toBe(true); + expect(body.pollerIntervalMs).toBe(15000); + expect(body.externalUrl).toBe('https://tracearr.example.com'); + expect(body.basePath).toBe('/app'); + expect(body.trustProxy).toBe(true); + }); + + it('masks tautulli API key in response', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([mockSettingsRow]); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.tautulliApiKey).toBe('********'); + expect(body.tautulliUrl).toBe('http://localhost:8181'); + }); + + it('returns null for tautulliApiKey when not set', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([{ ...mockSettingsRow, tautulliApiKey: null }]); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.tautulliApiKey).toBe(null); + }); + + it('creates default settings when none exist', async () => { + app = await buildTestApp(ownerUser); + + // First select returns empty (no settings) + mockDbSelectLimit([]); + + // Then insert creates defaults + const defaultSettings = { + id: 1, + allowGuestAccess: false, + discordWebhookUrl: null, + customWebhookUrl: null, + pollerEnabled: true, + pollerIntervalMs: 15000, + tautulliUrl: null, + tautulliApiKey: null, + externalUrl: null, + basePath: '', + trustProxy: false, + mobileEnabled: false, + }; + mockDbInsert([defaultSettings]); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.allowGuestAccess).toBe(false); + }); + + it('rejects guest accessing settings', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(403); + expect(response.json().message).toContain('Only server owners'); + }); + + it('returns webhook format settings', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectLimit([{ ...mockSettingsRow, webhookFormat: 'ntfy', ntfyTopic: 'my-topic' }]); + + const response = await app.inject({ + method: 'GET', + url: '/settings', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.webhookFormat).toBe('ntfy'); + expect(body.ntfyTopic).toBe('my-topic'); + }); + }); + + describe('PATCH /settings', () => { + it('updates settings for owner', async () => { + app = await buildTestApp(ownerUser); + + // First check existing settings + mockDbSelectLimit([mockSettingsRow]); + mockDbUpdate(); + + // Return updated settings on final select + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ ...mockSettingsRow, allowGuestAccess: true }] + ), + }; + return chain as never; + }); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + allowGuestAccess: true, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.allowGuestAccess).toBe(true); + }); + + it('updates webhook URLs', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + discordWebhookUrl: 'https://new-discord-webhook.com', + customWebhookUrl: 'https://new-custom-webhook.com', + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + discordWebhookUrl: 'https://new-discord-webhook.com', + customWebhookUrl: 'https://new-custom-webhook.com', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.discordWebhookUrl).toBe('https://new-discord-webhook.com'); + expect(body.customWebhookUrl).toBe('https://new-custom-webhook.com'); + }); + + it('updates poller settings', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + pollerEnabled: false, + pollerIntervalMs: 30000, + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + pollerEnabled: false, + pollerIntervalMs: 30000, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.pollerEnabled).toBe(false); + expect(body.pollerIntervalMs).toBe(30000); + }); + + it('updates tautulli settings', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + tautulliUrl: 'http://tautulli:8181', + tautulliApiKey: 'new-api-key', + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + tautulliUrl: 'http://tautulli:8181', + tautulliApiKey: 'new-api-key', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.tautulliUrl).toBe('http://tautulli:8181'); + expect(body.tautulliApiKey).toBe('********'); // Should be masked + }); + + it('updates network settings and normalizes externalUrl', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + externalUrl: 'https://new-url.com', // Should strip trailing slash + trustProxy: false, + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + externalUrl: 'https://new-url.com/', // With trailing slash + trustProxy: false, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.externalUrl).toBe('https://new-url.com'); + expect(body.trustProxy).toBe(false); + }); + + it('normalizes basePath by adding leading slash', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + basePath: '/custom-path', // Should have leading slash + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + basePath: 'custom-path', // Without leading slash + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.basePath).toBe('/custom-path'); + }); + + it('creates settings when none exist', async () => { + app = await buildTestApp(ownerUser); + + // First select returns empty (no settings) + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [] // No existing settings + : [{ ...mockSettingsRow, allowGuestAccess: true }] // After insert + ), + }; + return chain as never; + }); + + const insertChain = { + values: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.insert).mockReturnValue(insertChain as never); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + allowGuestAccess: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(db.insert).toHaveBeenCalled(); + }); + + it('rejects guest updating settings', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + allowGuestAccess: true, + }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().message).toContain('Only server owners'); + }); + + it('rejects invalid request body', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + pollerIntervalMs: 'not-a-number', // Should be number + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('handles empty update body', async () => { + app = await buildTestApp(ownerUser); + + vi.mocked(db.select).mockImplementation(() => { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([mockSettingsRow]), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: {}, + }); + + expect(response.statusCode).toBe(200); + // Should still update the updatedAt timestamp + expect(db.update).toHaveBeenCalled(); + }); + + it('clears webhook URLs when set to null', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + discordWebhookUrl: null, + customWebhookUrl: null, + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + discordWebhookUrl: null, + customWebhookUrl: null, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.discordWebhookUrl).toBe(null); + expect(body.customWebhookUrl).toBe(null); + }); + + it('updates webhook format to ntfy', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + webhookFormat: 'ntfy', + ntfyTopic: 'tracearr-alerts', + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + webhookFormat: 'ntfy', + ntfyTopic: 'tracearr-alerts', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.webhookFormat).toBe('ntfy'); + expect(body.ntfyTopic).toBe('tracearr-alerts'); + }); + + it('updates webhook format to apprise', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [mockSettingsRow] + : [{ + ...mockSettingsRow, + webhookFormat: 'apprise', + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + webhookFormat: 'apprise', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.webhookFormat).toBe('apprise'); + }); + + it('rejects invalid webhook format', async () => { + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + webhookFormat: 'invalid-format', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('clears ntfy topic when set to null', async () => { + app = await buildTestApp(ownerUser); + + let selectCount = 0; + vi.mocked(db.select).mockImplementation(() => { + selectCount++; + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue( + selectCount === 1 + ? [{ ...mockSettingsRow, ntfyTopic: 'old-topic' }] + : [{ + ...mockSettingsRow, + ntfyTopic: null, + }] + ), + }; + return chain as never; + }); + mockDbUpdate(); + + const response = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + ntfyTopic: null, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.ntfyTopic).toBe(null); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/setup.test.ts b/apps/server/src/routes/__tests__/setup.test.ts new file mode 100644 index 0000000..f63177d --- /dev/null +++ b/apps/server/src/routes/__tests__/setup.test.ts @@ -0,0 +1,264 @@ +/** + * Setup routes unit tests + * + * Tests the API endpoint for checking Tracearr configuration status: + * - GET /status - Check if setup is needed + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + }, +})); + +// Import the mocked db and the routes +import { db } from '../../db/client.js'; +import { setupRoutes } from '../setup.js'; + +/** + * Helper to mock db.select with multiple chained calls + * Setup route uses Promise.all with 4 parallel queries: + * 1. All servers + * 2. Jellyfin servers (where type = 'jellyfin') + * 3. Owners (where role = 'owner') + * 4. Password users (where passwordHash is not null) + * Plus a 5th query for settings (which may fail and default to 'local') + */ +function mockDbSelectMultiple(results: unknown[][]) { + let callIndex = 0; + const createChain = () => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockImplementation(() => { + return Promise.resolve(results[callIndex++] || []); + }), + }); + + vi.mocked(db.select).mockImplementation(() => createChain() as never); +} + +/** + * Build a test Fastify instance + * Note: Setup routes are public (no auth required) + */ +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Register routes + await app.register(setupRoutes, { prefix: '/setup' }); + + return app; +} + +describe('Setup Routes', () => { + let app: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /setup/status', () => { + it('returns needsSetup true when no owners exist', async () => { + app = await buildTestApp(); + + // Mock: servers exist, no jellyfin servers, no owners, no password users + mockDbSelectMultiple([ + [{ id: 'server-1' }], // servers query + [], // jellyfin servers query + [], // owners query (empty = needs setup) + [], // password users query + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: true, + hasServers: true, + hasJellyfinServers: false, + hasPasswordAuth: false, + primaryAuthMethod: 'local', + }); + }); + + it('returns needsSetup false when owner exists', async () => { + app = await buildTestApp(); + + // Mock: servers exist, jellyfin servers exist, owner exists, password user exists + mockDbSelectMultiple([ + [{ id: 'server-1' }], // servers query + [{ id: 'server-1' }], // jellyfin servers query + [{ id: 'user-1' }], // owners query (has owner) + [{ id: 'user-1' }], // password users query + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: false, + hasServers: true, + hasJellyfinServers: true, + hasPasswordAuth: true, + primaryAuthMethod: 'local', + }); + }); + + it('returns hasServers false when no servers configured', async () => { + app = await buildTestApp(); + + // Mock: no servers, no jellyfin servers, no owners, no password users + mockDbSelectMultiple([ + [], // servers query (empty) + [], // jellyfin servers query + [], // owners query + [], // password users query + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: true, + hasServers: false, + hasJellyfinServers: false, + hasPasswordAuth: false, + primaryAuthMethod: 'local', + }); + }); + + it('returns hasPasswordAuth true when user has password set', async () => { + app = await buildTestApp(); + + // Mock: no servers, no jellyfin servers, owner exists, password user exists + mockDbSelectMultiple([ + [], // servers query + [], // jellyfin servers query + [{ id: 'user-1' }], // owners query + [{ id: 'user-1' }], // password users query (has password) + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: false, + hasServers: false, + hasJellyfinServers: false, + hasPasswordAuth: true, + primaryAuthMethod: 'local', + }); + }); + + it('returns hasPasswordAuth false when no users have passwords', async () => { + app = await buildTestApp(); + + // Mock: servers exist, jellyfin servers exist, owner exists, no password users + mockDbSelectMultiple([ + [{ id: 'server-1' }], // servers query + [{ id: 'server-1' }], // jellyfin servers query + [{ id: 'user-1' }], // owners query + [], // password users query (empty) + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: false, + hasServers: true, + hasJellyfinServers: true, + hasPasswordAuth: false, + primaryAuthMethod: 'local', + }); + }); + + it('handles fresh installation state correctly', async () => { + app = await buildTestApp(); + + // Mock: completely empty database + mockDbSelectMultiple([ + [], // no servers + [], // no jellyfin servers + [], // no owners + [], // no password users + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: true, + hasServers: false, + hasJellyfinServers: false, + hasPasswordAuth: false, + primaryAuthMethod: 'local', + }); + }); + + it('handles fully configured state correctly', async () => { + app = await buildTestApp(); + + // Mock: fully configured installation + mockDbSelectMultiple([ + [{ id: 'server-1' }, { id: 'server-2' }], // multiple servers + [{ id: 'server-1' }], // jellyfin servers + [{ id: 'owner-1' }], // owner exists + [{ id: 'owner-1' }, { id: 'user-2' }], // multiple password users + ]); + + const response = await app.inject({ + method: 'GET', + url: '/setup/status', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body).toEqual({ + needsSetup: false, + hasServers: true, + hasJellyfinServers: true, + hasPasswordAuth: true, + primaryAuthMethod: 'local', + }); + }); + }); +}); diff --git a/apps/server/src/routes/__tests__/violations.test.ts b/apps/server/src/routes/__tests__/violations.test.ts new file mode 100644 index 0000000..2f7a9d3 --- /dev/null +++ b/apps/server/src/routes/__tests__/violations.test.ts @@ -0,0 +1,769 @@ +/** + * Violation routes integration tests + * + * Tests the API endpoints for violation operations: + * - GET /violations - List violations with pagination and filters + * - GET /violations/:id - Get a specific violation + * - PATCH /violations/:id - Acknowledge a violation + * - DELETE /violations/:id - Dismiss (delete) a violation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser, ViolationSeverity } from '@tracearr/shared'; + +// Mock the database module before importing routes +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + execute: vi.fn(), + }, +})); + +// Import the mocked db and the routes +import { db } from '../../db/client.js'; +import { violationRoutes } from '../violations.js'; + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: any) => { + request.user = authUser; + }); + + // Register routes + await app.register(violationRoutes, { prefix: '/violations' }); + + return app; +} + +/** + * Create a mock violation with joined data (as returned by routes) + */ +interface MockViolationWithJoins { + id: string; + ruleId: string; + ruleName: string; + ruleType: string; + serverUserId: string; + username: string; + userThumb: string | null; + identityName: string | null; + serverId: string; + serverName: string; + sessionId: string; + mediaTitle: string; + severity: ViolationSeverity; + data: Record; + createdAt: Date; + acknowledgedAt: Date | null; + ipAddress?: string; + geoCity?: string | null; + geoCountry?: string | null; + playerName?: string | null; + platform?: string | null; +} + +function createTestViolation( + overrides: Partial = {} +): MockViolationWithJoins { + const serverId = overrides.serverId ?? randomUUID(); + return { + id: overrides.id ?? randomUUID(), + ruleId: overrides.ruleId ?? randomUUID(), + ruleName: overrides.ruleName ?? 'Test Rule', + ruleType: overrides.ruleType ?? 'concurrent_streams', + serverUserId: overrides.serverUserId ?? randomUUID(), + username: overrides.username ?? 'testuser', + userThumb: overrides.userThumb ?? null, + identityName: overrides.identityName ?? null, + serverId, + serverName: overrides.serverName ?? 'Test Server', + sessionId: overrides.sessionId ?? randomUUID(), + mediaTitle: overrides.mediaTitle ?? 'Test Movie', + severity: overrides.severity ?? 'warning', + data: overrides.data ?? { maxStreams: 3, actualStreams: 4 }, + createdAt: overrides.createdAt ?? new Date(), + acknowledgedAt: overrides.acknowledgedAt ?? null, + ipAddress: overrides.ipAddress ?? '192.168.1.1', + geoCity: overrides.geoCity ?? 'New York', + geoCountry: overrides.geoCountry ?? 'US', + playerName: overrides.playerName ?? 'Test Player', + platform: overrides.platform ?? 'Windows', + }; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds: [randomUUID()], + }; +} + +/** + * Create a mock viewer auth user (non-owner) + */ +function createViewerUser(): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], + }; +} + +/** + * Helper to create the mock chain for violation queries with 5 innerJoins + * (rules, serverUsers, users, servers, sessions) + */ +function createViolationSelectMock(resolvedValue: unknown) { + return { + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + offset: vi.fn().mockResolvedValue(resolvedValue), + }), + }), + }), + }), + }), + }), + }), + }), + }), + }; +} + +/** + * Helper to create the mock chain for single violation queries (GET /:id) + * with 5 innerJoins (rules, serverUsers, users, servers, sessions) + */ +function createSingleViolationSelectMock(resolvedValue: unknown) { + return { + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(resolvedValue), + }), + }), + }), + }), + }), + }), + }), + }; +} + +/** + * Helper to create mock for violation existence check (PATCH/DELETE) + * Uses serverUsers join for server access check + */ +function createViolationExistsCheckMock(resolvedValue: unknown) { + return { + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(resolvedValue), + }), + }), + }), + }; +} + +describe('Violation Routes', () => { + let app: FastifyInstance; + let mockDb: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockDb = db as any; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /violations', () => { + it('should return list of violations for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const testViolations = [ + createTestViolation({ severity: 'high' }), + createTestViolation({ severity: 'warning' }), + createTestViolation({ severity: 'low' }), + ]; + + // Mock the violations query (4 innerJoins) + mockDb.select.mockReturnValueOnce(createViolationSelectMock(testViolations)); + + // Mock the count query (uses db.execute with raw SQL) + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 3 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(3); + expect(body.total).toBe(3); + expect(body.page).toBe(1); + }); + + it('should apply default pagination', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.select.mockReturnValueOnce(createViolationSelectMock([])); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.page).toBe(1); + expect(body.pageSize).toBe(20); // Schema default is 20 + }); + + it('should accept pagination parameters', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.select.mockReturnValueOnce(createViolationSelectMock([])); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 100 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations?page=3&pageSize=25', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.page).toBe(3); + expect(body.pageSize).toBe(25); + expect(body.totalPages).toBe(4); + }); + + it('should filter by severity', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const highSeverityViolations = [ + createTestViolation({ severity: 'high' }), + ]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(highSeverityViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations?severity=high', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].severity).toBe('high'); + }); + + it('should filter by acknowledged status', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const unacknowledgedViolations = [ + createTestViolation({ acknowledgedAt: null }), + ]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(unacknowledgedViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations?acknowledged=false', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].acknowledgedAt).toBeNull(); + }); + + it('should filter by serverUserId', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const serverUserId = randomUUID(); + const userViolations = [createTestViolation({ serverUserId })]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(userViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: `/violations?serverUserId=${serverUserId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + }); + + it('should filter by ruleId', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const ruleId = randomUUID(); + const ruleViolations = [createTestViolation({ ruleId })]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(ruleViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: `/violations?ruleId=${ruleId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + }); + + it('should reject invalid severity filter', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/violations?severity=critical', + }); + + expect(response.statusCode).toBe(400); + }); + + it('should reject pageSize over 100', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/violations?pageSize=101', + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return empty data for viewers with no server access', async () => { + // Viewer with empty serverIds returns empty result without querying + const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [], + }; + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/violations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(0); + expect(body.total).toBe(0); + }); + }); + + describe('GET /violations/:id', () => { + it('should return a specific violation', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const testViolation = createTestViolation({ id: violationId }); + + mockDb.select.mockReturnValue(createSingleViolationSelectMock([testViolation])); + + const response = await app.inject({ + method: 'GET', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(violationId); + expect(body.ruleName).toBe('Test Rule'); + expect(body.username).toBe('testuser'); + expect(body.serverName).toBe('Test Server'); + }); + + it('should return 404 for non-existent violation', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.select.mockReturnValue(createSingleViolationSelectMock([])); + + const response = await app.inject({ + method: 'GET', + url: `/violations/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should reject invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/violations/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + + it('should return violation with session details', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const testViolation = createTestViolation({ + id: violationId, + ipAddress: '10.0.0.1', + geoCity: 'Los Angeles', + geoCountry: 'US', + playerName: 'Plex Player', + platform: 'macOS', + }); + + mockDb.select.mockReturnValue(createSingleViolationSelectMock([testViolation])); + + const response = await app.inject({ + method: 'GET', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.ipAddress).toBe('10.0.0.1'); + expect(body.geoCity).toBe('Los Angeles'); + expect(body.geoCountry).toBe('US'); + expect(body.playerName).toBe('Plex Player'); + expect(body.platform).toBe('macOS'); + }); + }); + + describe('PATCH /violations/:id', () => { + it('should acknowledge violation for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const serverId = ownerUser.serverIds[0]; + const acknowledgedAt = new Date(); + + // Violation exists check with serverUsers join + mockDb.select.mockReturnValue(createViolationExistsCheckMock([{ id: violationId, serverId }])); + + // Update + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: violationId, acknowledgedAt }]), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + expect(body.acknowledgedAt).toBeDefined(); + }); + + it('should reject acknowledgment for non-owner', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const response = await app.inject({ + method: 'PATCH', + url: `/violations/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should return 404 for non-existent violation', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.select.mockReturnValue(createViolationExistsCheckMock([])); + + const response = await app.inject({ + method: 'PATCH', + url: `/violations/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should reject invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'PATCH', + url: '/violations/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + + it('should handle update failure gracefully', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const serverId = ownerUser.serverIds[0]; + + // Violation exists check + mockDb.select.mockReturnValue(createViolationExistsCheckMock([{ id: violationId, serverId }])); + + // Update returns empty (failure) + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + }), + }), + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(500); + }); + }); + + describe('DELETE /violations/:id', () => { + it('should delete violation for owner', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const serverUserId = randomUUID(); + const serverId = ownerUser.serverIds[0]; + + // Violation exists check with serverUsers join - now includes severity and serverUserId + mockDb.select.mockReturnValue(createViolationExistsCheckMock([{ + id: violationId, + severity: 'warning', + serverUserId, + serverId, + }])); + + // Mock transaction for delete + trust score restore + mockDb.transaction = vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const txMock = { + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + return callback(txMock); + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + }); + + it('should restore trust score when deleting violation', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const violationId = randomUUID(); + const serverUserId = randomUUID(); + const serverId = ownerUser.serverIds[0]; + + // Test with high severity (penalty: 20) + mockDb.select.mockReturnValue(createViolationExistsCheckMock([{ + id: violationId, + severity: 'high', + serverUserId, + serverId, + }])); + + // Track transaction calls + const deleteMock = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }); + const updateMock = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }); + + mockDb.transaction = vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const txMock = { + delete: deleteMock, + update: updateMock, + }; + return callback(txMock); + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/violations/${violationId}`, + }); + + expect(response.statusCode).toBe(200); + // Verify transaction was called + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + // Verify delete and update were called in transaction + expect(deleteMock).toHaveBeenCalled(); + expect(updateMock).toHaveBeenCalled(); + }); + + it('should reject delete for non-owner', async () => { + const guestUser = createViewerUser(); + app = await buildTestApp(guestUser); + + const response = await app.inject({ + method: 'DELETE', + url: `/violations/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(403); + }); + + it('should return 404 for non-existent violation', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + mockDb.select.mockReturnValue(createViolationExistsCheckMock([])); + + const response = await app.inject({ + method: 'DELETE', + url: `/violations/${randomUUID()}`, + }); + + expect(response.statusCode).toBe(404); + }); + + it('should reject invalid UUID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'DELETE', + url: '/violations/not-a-uuid', + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('Authorization', () => { + it('should allow owner to see all violations', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const testViolations = [ + createTestViolation({ serverUserId: randomUUID() }), + createTestViolation({ serverUserId: randomUUID() }), + ]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(testViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 2 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(2); + }); + + it('should filter violations by server access for viewers', async () => { + const viewerServerId = randomUUID(); + const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [viewerServerId], + }; + app = await buildTestApp(viewerUser); + + // Return violations from the viewer's accessible server + const testViolations = [ + createTestViolation({ serverId: viewerServerId }), + ]; + + mockDb.select.mockReturnValueOnce(createViolationSelectMock(testViolations)); + mockDb.execute.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const response = await app.inject({ + method: 'GET', + url: '/violations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].user.serverId).toBe(viewerServerId); + }); + }); +}); diff --git a/apps/server/src/routes/auth.security.test.ts b/apps/server/src/routes/auth.security.test.ts new file mode 100644 index 0000000..4d0780c --- /dev/null +++ b/apps/server/src/routes/auth.security.test.ts @@ -0,0 +1,379 @@ +/** + * Auth Security Tests + * + * Tests to ensure authentication and authorization cannot be bypassed. + * Covers: token validation, privilege escalation, injection attacks. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { + createTestApp, + generateTestToken, + createOwnerPayload, + createViewerPayload, + generateExpiredToken, + generateTamperedToken, + generateWrongSecretToken, + INJECTION_PAYLOADS, +} from '../test/helpers.js'; + +describe('Auth Security', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await createTestApp(); + + // Add a protected test route that requires authentication + app.get('/test/protected', { preHandler: [app.authenticate] }, async (request) => { + return { user: request.user, message: 'authenticated' }; + }); + + // Add an owner-only test route + app.get('/test/owner-only', { preHandler: [app.requireOwner] }, async (request) => { + return { user: request.user, message: 'owner access granted' }; + }); + + // Add a route that echoes back user input (for injection testing) + app.post('/test/echo', async (request) => { + const body = request.body as { input?: string }; + return { received: body.input }; + }); + + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Token Validation', () => { + it('should reject requests with no token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('Invalid or expired token'); + }); + + it('should reject requests with empty Authorization header', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: '' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject requests with malformed Authorization header', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: 'not-a-bearer-token' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject requests with Bearer but no token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: 'Bearer ' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject expired tokens', async () => { + const expiredToken = generateExpiredToken(app, createOwnerPayload()); + + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${expiredToken}` }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('Invalid or expired token'); + }); + + it('should reject tampered tokens', async () => { + const validToken = generateTestToken(app, createViewerPayload()); + const tamperedToken = generateTamperedToken(validToken); + + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${tamperedToken}` }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject tokens signed with wrong secret', async () => { + const wrongSecretToken = generateWrongSecretToken(createOwnerPayload()); + + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${wrongSecretToken}` }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject random garbage tokens', async () => { + const garbageTokens = [ + 'not.a.jwt', + 'aaa.bbb.ccc', + Buffer.from('garbage').toString('base64'), + '{"userId":"hack"}', + 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOiJoYWNrIn0.', + ]; + + for (const garbage of garbageTokens) { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${garbage}` }, + }); + + expect(res.statusCode).toBe(401); + } + }); + + it('should accept valid tokens', async () => { + const validToken = generateTestToken(app, createOwnerPayload()); + + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${validToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('authenticated'); + }); + + it('should preserve user data from valid token', async () => { + const payload = createOwnerPayload({ username: 'securitytest' }); + const token = generateTestToken(app, payload); + + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.user.username).toBe('securitytest'); + expect(json.user.role).toBe('owner'); + }); + }); + + describe('Authorization - Owner-Only Routes', () => { + it('should reject guest users on owner-only routes', async () => { + const guestToken = generateTestToken(app, createViewerPayload()); + + const res = await app.inject({ + method: 'GET', + url: '/test/owner-only', + headers: { Authorization: `Bearer ${guestToken}` }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().message).toContain('Owner access required'); + }); + + it('should accept owner users on owner-only routes', async () => { + const ownerToken = generateTestToken(app, createOwnerPayload()); + + const res = await app.inject({ + method: 'GET', + url: '/test/owner-only', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('owner access granted'); + }); + + it('should reject unauthenticated users on owner-only routes with 401', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/owner-only', + }); + + // Should return 401, not 403 (auth before authz) + expect(res.statusCode).toBe(401); + }); + + it('should prevent role escalation via token manipulation', async () => { + // Create a guest token and try to tamper it to become owner + const guestToken = generateTestToken(app, createViewerPayload()); + + // Try various tampering techniques + const tamperedTokens = [ + generateTamperedToken(guestToken), // Modify payload, keep sig + guestToken.replace('guest', 'owner'), // Naive string replace + ]; + + for (const tampered of tamperedTokens) { + const res = await app.inject({ + method: 'GET', + url: '/test/owner-only', + headers: { Authorization: `Bearer ${tampered}` }, + }); + + // Should either reject as invalid (401) or as unauthorized (403) + expect([401, 403]).toContain(res.statusCode); + } + }); + }); + + describe('Injection Prevention', () => { + it('should safely handle SQL injection payloads in input', async () => { + for (const payload of INJECTION_PAYLOADS.sqlInjection) { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: payload }, + }); + + // Server should not crash and should echo back the input safely + expect(res.statusCode).toBe(200); + const json = res.json(); + // The payload should be treated as a string, not executed + expect(json.received).toBe(payload); + } + }); + + it('should safely handle XSS payloads in input', async () => { + for (const payload of INJECTION_PAYLOADS.xss) { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: payload }, + }); + + expect(res.statusCode).toBe(200); + // XSS prevention is mainly a frontend concern, but backend should not crash + expect(res.json().received).toBe(payload); + } + }); + + it('should safely handle path traversal payloads', async () => { + for (const payload of INJECTION_PAYLOADS.pathTraversal) { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: payload }, + }); + + expect(res.statusCode).toBe(200); + } + }); + + it('should handle extremely long input without crashing', async () => { + const longInput = 'A'.repeat(100000); + + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: longInput }, + }); + + // Should either accept or reject, but not crash + expect([200, 413, 400]).toContain(res.statusCode); + }); + + it('should handle null bytes in input', async () => { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: 'test\x00injection' }, + }); + + expect(res.statusCode).toBe(200); + }); + + it('should handle unicode edge cases', async () => { + const unicodePayloads = [ + '\u202E\u0041\u0042\u0043', // Right-to-left override + '\uFEFF\uFEFF\uFEFF', // BOM characters + '𝕳𝖊𝖑𝖑𝖔', // Mathematical symbols + '❤️💻🔒', // Emoji + ]; + + for (const payload of unicodePayloads) { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: { input: payload }, + }); + + expect(res.statusCode).toBe(200); + } + }); + }); + + describe('Header Security', () => { + it('should not expose sensitive info in error responses', async () => { + const res = await app.inject({ + method: 'GET', + url: '/test/protected', + }); + + const body = res.json(); + + // Error should not leak stack traces or internal paths + expect(JSON.stringify(body)).not.toContain('node_modules'); + expect(JSON.stringify(body)).not.toContain('at Object'); + expect(JSON.stringify(body)).not.toContain('.ts:'); + expect(JSON.stringify(body)).not.toContain('JWT_SECRET'); + }); + + it('should handle missing Content-Type gracefully', async () => { + const res = await app.inject({ + method: 'POST', + url: '/test/echo', + payload: '{"input":"test"}', + // No content-type header + }); + + // Should handle gracefully, not crash + expect([200, 400, 415]).toContain(res.statusCode); + }); + }); + + describe('Token Expiration Edge Cases', () => { + it('should handle tokens that expire during request', async () => { + // Token with 1 second expiry + const shortLivedToken = generateTestToken(app, createOwnerPayload(), { expiresIn: '1s' }); + + // First request should work + const res1 = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${shortLivedToken}` }, + }); + expect(res1.statusCode).toBe(200); + + // Wait for expiry + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Second request should fail + const res2 = await app.inject({ + method: 'GET', + url: '/test/protected', + headers: { Authorization: `Bearer ${shortLivedToken}` }, + }); + expect(res2.statusCode).toBe(401); + }); + }); +}); diff --git a/apps/server/src/routes/auth/__tests__/plex.test.ts b/apps/server/src/routes/auth/__tests__/plex.test.ts new file mode 100644 index 0000000..91c774b --- /dev/null +++ b/apps/server/src/routes/auth/__tests__/plex.test.ts @@ -0,0 +1,389 @@ +/** + * Plex auth routes tests + * + * Tests the API endpoints for Plex server discovery and connection: + * - GET /plex/available-servers - Discover available Plex servers + * - POST /plex/add-server - Add an additional Plex server + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock dependencies before imports +vi.mock('../../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('../../../utils/crypto.js', () => ({ + encrypt: vi.fn((token: string) => `encrypted_${token}`), + decrypt: vi.fn((token: string) => token.replace('encrypted_', '')), +})); + +vi.mock('../../../services/mediaServer/index.js', () => ({ + PlexClient: { + getServers: vi.fn(), + verifyServerAdmin: vi.fn(), + }, +})); + +vi.mock('../../../services/sync.js', () => ({ + syncServer: vi.fn(), +})); + +// Import mocked modules +import { db } from '../../../db/client.js'; +import { PlexClient } from '../../../services/mediaServer/index.js'; +import { syncServer } from '../../../services/sync.js'; +import { plexRoutes } from '../plex.js'; + +// Mock global fetch for connection testing +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Helper to create DB chain mocks +function mockDbSelectWhere(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +// For queries that end with .limit() +function mockDbSelectLimit(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +function mockDbInsert(result: unknown[]) { + const chain = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + return chain; +} + +function _mockDbUpdate() { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + return chain; +} + +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + await app.register(sensible); + + // Mock authenticate + app.decorate('authenticate', async (request: unknown) => { + (request as { user: AuthUser }).user = authUser; + }); + + await app.register(plexRoutes); + return app; +} + +const ownerUser: AuthUser = { + userId: randomUUID(), + username: 'admin', + role: 'owner', + serverIds: [randomUUID()], +}; + +const viewerUser: AuthUser = { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds: [randomUUID()], +}; + +const mockExistingServer = { + id: randomUUID(), + name: 'Existing Plex Server', + type: 'plex' as const, + url: 'http://localhost:32400', + token: 'encrypted_test-token', + machineIdentifier: 'existing-machine-id', + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockPlexServer = { + name: 'New Plex Server', + product: 'Plex Media Server', + platform: 'Linux', + productVersion: '1.40.0', + clientIdentifier: 'new-machine-id', + owned: true, + accessToken: 'server-access-token', + publicAddress: '203.0.113.1', + connections: [ + { protocol: 'http', uri: 'http://192.168.1.100:32400', local: true, address: '192.168.1.100', port: 32400 }, + { protocol: 'https', uri: 'https://plex.example.com:32400', local: false, address: 'plex.example.com', port: 32400 }, + ], +}; + +describe('Plex Auth Routes', () => { + let app: FastifyInstance; + + afterEach(async () => { + await app?.close(); + vi.clearAllMocks(); + }); + + describe('GET /plex/available-servers', () => { + it('returns 403 for non-owner users', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'GET', + url: '/plex/available-servers', + }); + + expect(response.statusCode).toBe(403); + }); + + it('returns hasPlexToken: false when no Plex servers connected', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectWhere([]); + + const response = await app.inject({ + method: 'GET', + url: '/plex/available-servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.hasPlexToken).toBe(false); + expect(body.servers).toEqual([]); + }); + + it('returns empty servers when all owned servers are connected', async () => { + app = await buildTestApp(ownerUser); + + // First call returns existing servers + mockDbSelectWhere([mockExistingServer]); + + // Mock PlexClient.getServers to return only the existing server + vi.mocked(PlexClient.getServers).mockResolvedValue([ + { + ...mockPlexServer, + clientIdentifier: mockExistingServer.machineIdentifier, + }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/plex/available-servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.hasPlexToken).toBe(true); + expect(body.servers).toEqual([]); + }); + + it('returns available servers with connection test results', async () => { + app = await buildTestApp(ownerUser); + + mockDbSelectWhere([mockExistingServer]); + + // Return a new server not yet connected + vi.mocked(PlexClient.getServers).mockResolvedValue([mockPlexServer]); + + // Mock fetch for connection testing - first succeeds, second fails + mockFetch + .mockResolvedValueOnce({ ok: true }) // Local connection succeeds + .mockRejectedValueOnce(new Error('timeout')); // Remote connection fails + + const response = await app.inject({ + method: 'GET', + url: '/plex/available-servers', + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.hasPlexToken).toBe(true); + expect(body.servers).toHaveLength(1); + expect(body.servers[0].name).toBe('New Plex Server'); + expect(body.servers[0].clientIdentifier).toBe('new-machine-id'); + expect(body.servers[0].connections).toHaveLength(2); + // First connection should be reachable + expect(body.servers[0].connections[0].reachable).toBe(true); + // Second connection should be unreachable + expect(body.servers[0].connections[1].reachable).toBe(false); + }); + }); + + describe('POST /plex/add-server', () => { + it('returns 403 for non-owner users', async () => { + app = await buildTestApp(viewerUser); + + const response = await app.inject({ + method: 'POST', + url: '/plex/add-server', + payload: { + serverUri: 'http://192.168.1.100:32400', + serverName: 'New Server', + clientIdentifier: 'new-machine-id', + }, + }); + + expect(response.statusCode).toBe(403); + }); + + it('returns 400 when no Plex servers connected', async () => { + app = await buildTestApp(ownerUser); + + // Mock the DB query with limit() returning empty + mockDbSelectLimit([]); + + const response = await app.inject({ + method: 'POST', + url: '/plex/add-server', + payload: { + serverUri: 'http://192.168.1.100:32400', + serverName: 'New Server', + clientIdentifier: 'new-machine-id', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toContain('No Plex servers connected'); + }); + + it('returns 409 when server is already connected', async () => { + app = await buildTestApp(ownerUser); + + // Mock all three limit() calls: + // 1. Get existing Plex server (has token) + // 2. Check machineIdentifier duplicate (found - conflict!) + const selectMock = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn() + .mockResolvedValueOnce([{ token: mockExistingServer.token }]) // First - get token + .mockResolvedValueOnce([{ id: mockExistingServer.id }]) // Second - duplicate found + }; + vi.mocked(db.select).mockReturnValue(selectMock as never); + + const response = await app.inject({ + method: 'POST', + url: '/plex/add-server', + payload: { + serverUri: 'http://192.168.1.100:32400', + serverName: 'New Server', + clientIdentifier: mockExistingServer.machineIdentifier, + }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.message).toContain('already connected'); + }); + + it('successfully adds a new server', async () => { + app = await buildTestApp(ownerUser); + + const newServerId = randomUUID(); + const newServer = { + id: newServerId, + name: 'New Server', + type: 'plex', + url: 'http://192.168.1.100:32400', + token: 'encrypted_test-token', + machineIdentifier: 'new-machine-id', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Mock all three limit() calls: + // 1. Get existing Plex server (has token) + // 2. Check machineIdentifier duplicate (not found) + // 3. Check URL duplicate (not found) + const selectMock = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn() + .mockResolvedValueOnce([{ token: mockExistingServer.token }]) // First - get token + .mockResolvedValueOnce([]) // Second - no machineIdentifier duplicate + .mockResolvedValueOnce([]) // Third - no URL duplicate + }; + vi.mocked(db.select).mockReturnValue(selectMock as never); + + // Mock admin verification + vi.mocked(PlexClient.verifyServerAdmin).mockResolvedValue(true); + + // Mock insert + mockDbInsert([newServer]); + + // Mock sync + vi.mocked(syncServer).mockResolvedValue({ usersAdded: 5, usersUpdated: 0, librariesSynced: 3, errors: [] }); + + const response = await app.inject({ + method: 'POST', + url: '/plex/add-server', + payload: { + serverUri: 'http://192.168.1.100:32400', + serverName: 'New Server', + clientIdentifier: 'new-machine-id', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.server.id).toBe(newServerId); + expect(body.success).toBe(true); + }); + + it('returns 403 when not admin on server', async () => { + app = await buildTestApp(ownerUser); + + // Mock all three limit() calls + const selectMock = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn() + .mockResolvedValueOnce([{ token: mockExistingServer.token }]) // Get token + .mockResolvedValueOnce([]) // No machineIdentifier duplicate + .mockResolvedValueOnce([]) // No URL duplicate + }; + vi.mocked(db.select).mockReturnValue(selectMock as never); + + // Mock admin verification - not admin + vi.mocked(PlexClient.verifyServerAdmin).mockResolvedValue(false); + + const response = await app.inject({ + method: 'POST', + url: '/plex/add-server', + payload: { + serverUri: 'http://192.168.1.100:32400', + serverName: 'New Server', + clientIdentifier: 'new-machine-id', + }, + }); + + expect(response.statusCode).toBe(403); + const body = response.json(); + expect(body.message).toContain('admin'); + }); + }); +}); diff --git a/apps/server/src/routes/auth/__tests__/utils.test.ts b/apps/server/src/routes/auth/__tests__/utils.test.ts new file mode 100644 index 0000000..8a2e39d --- /dev/null +++ b/apps/server/src/routes/auth/__tests__/utils.test.ts @@ -0,0 +1,143 @@ +/** + * Auth Route Utilities Tests + * + * Tests pure utility functions from routes/auth/utils.ts: + * - generateRefreshToken: Generate random refresh tokens + * - hashRefreshToken: Hash tokens for secure storage + * - generateTempToken: Generate temporary OAuth tokens + */ + +import { describe, it, expect } from 'vitest'; +import { + generateRefreshToken, + hashRefreshToken, + generateTempToken, + REFRESH_TOKEN_PREFIX, + PLEX_TEMP_TOKEN_PREFIX, + REFRESH_TOKEN_TTL, + PLEX_TEMP_TOKEN_TTL, +} from '../utils.js'; + +describe('generateRefreshToken', () => { + it('should generate a 64 character hex string', () => { + const token = generateRefreshToken(); + expect(token).toHaveLength(64); // 32 bytes = 64 hex chars + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it('should generate unique tokens each call', () => { + const token1 = generateRefreshToken(); + const token2 = generateRefreshToken(); + const token3 = generateRefreshToken(); + + expect(token1).not.toBe(token2); + expect(token2).not.toBe(token3); + expect(token1).not.toBe(token3); + }); + + it('should generate cryptographically random tokens', () => { + // Generate many tokens and verify no collisions + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateRefreshToken()); + } + expect(tokens.size).toBe(100); + }); +}); + +describe('hashRefreshToken', () => { + it('should return a 64 character SHA-256 hex hash', () => { + const hash = hashRefreshToken('test-token'); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[a-f0-9]+$/); + }); + + it('should produce consistent hashes for the same input', () => { + const token = 'my-refresh-token'; + const hash1 = hashRefreshToken(token); + const hash2 = hashRefreshToken(token); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different inputs', () => { + const hash1 = hashRefreshToken('token-1'); + const hash2 = hashRefreshToken('token-2'); + expect(hash1).not.toBe(hash2); + }); + + it('should hash empty string without error', () => { + const hash = hashRefreshToken(''); + expect(hash).toHaveLength(64); + // SHA-256 of empty string + expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + it('should be one-way (cannot derive original token)', () => { + const token = generateRefreshToken(); + const hash = hashRefreshToken(token); + // Hash should not contain the token + expect(hash).not.toContain(token); + // Hash length is different from token length + expect(hash.length).toBe(token.length); // Both 64 but different content + }); +}); + +describe('generateTempToken', () => { + it('should generate a 48 character hex string', () => { + const token = generateTempToken(); + expect(token).toHaveLength(48); // 24 bytes = 48 hex chars + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it('should generate unique tokens each call', () => { + const token1 = generateTempToken(); + const token2 = generateTempToken(); + expect(token1).not.toBe(token2); + }); +}); + +describe('Constants', () => { + describe('Redis key prefixes', () => { + it('should have correct REFRESH_TOKEN_PREFIX', () => { + expect(REFRESH_TOKEN_PREFIX).toBe('tracearr:refresh:'); + }); + + it('should have correct PLEX_TEMP_TOKEN_PREFIX', () => { + expect(PLEX_TEMP_TOKEN_PREFIX).toBe('tracearr:plex_temp:'); + }); + }); + + describe('TTL values', () => { + it('should have REFRESH_TOKEN_TTL of 30 days in seconds', () => { + expect(REFRESH_TOKEN_TTL).toBe(30 * 24 * 60 * 60); + }); + + it('should have PLEX_TEMP_TOKEN_TTL of 10 minutes in seconds', () => { + expect(PLEX_TEMP_TOKEN_TTL).toBe(10 * 60); + }); + }); +}); + +describe('Integration: Token workflow', () => { + it('should support generate -> hash -> lookup workflow', () => { + // Simulate token creation and storage lookup + const refreshToken = generateRefreshToken(); + const storedHash = hashRefreshToken(refreshToken); + + // User sends token back, we hash it to look up + const lookupHash = hashRefreshToken(refreshToken); + + // Should match for lookup + expect(lookupHash).toBe(storedHash); + }); + + it('should reject different token in lookup', () => { + const originalToken = generateRefreshToken(); + const storedHash = hashRefreshToken(originalToken); + + const differentToken = generateRefreshToken(); + const lookupHash = hashRefreshToken(differentToken); + + expect(lookupHash).not.toBe(storedHash); + }); +}); diff --git a/apps/server/src/routes/auth/emby.ts b/apps/server/src/routes/auth/emby.ts new file mode 100644 index 0000000..11dcc91 --- /dev/null +++ b/apps/server/src/routes/auth/emby.ts @@ -0,0 +1,105 @@ +/** + * Emby Authentication Routes + * + * POST /emby/connect-api-key - Connect an Emby server with API key (requires authentication) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../../db/client.js'; +import { servers } from '../../db/schema.js'; +import { EmbyClient } from '../../services/mediaServer/index.js'; +// Token encryption removed - tokens now stored in plain text (DB is localhost-only) +import { generateTokens } from './utils.js'; +import { syncServer } from '../../services/sync.js'; + +// Schema for API key connection +const embyConnectApiKeySchema = z.object({ + serverUrl: z.url(), + serverName: z.string().min(1).max(100), + apiKey: z.string().min(1), +}); + +export const embyRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /emby/connect-api-key - Connect an Emby server with API key (requires authentication) + */ + app.post( + '/emby/connect-api-key', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = embyConnectApiKeySchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('serverUrl, serverName, and apiKey are required'); + } + + const authUser = request.user; + + // Only owners can add servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only owners can add servers'); + } + + const { serverUrl, serverName, apiKey } = body.data; + + try { + // Verify the API key has admin access + const isAdmin = await EmbyClient.verifyServerAdmin(apiKey, serverUrl); + + if (!isAdmin) { + return reply.forbidden('API key does not have administrator access to this Emby server'); + } + + // Create or update server + let server = await db + .select() + .from(servers) + .where(and(eq(servers.url, serverUrl), eq(servers.type, 'emby'))) + .limit(1); + + if (server.length === 0) { + const inserted = await db + .insert(servers) + .values({ + name: serverName, + type: 'emby', + url: serverUrl, + token: apiKey, + }) + .returning(); + server = inserted; + } else { + const existingServer = server[0]!; + await db + .update(servers) + .set({ + name: serverName, + token: apiKey, + updatedAt: new Date(), + }) + .where(eq(servers.id, existingServer.id)); + } + + const serverId = server[0]!.id; + + app.log.info({ userId: authUser.userId, serverId }, 'Emby server connected via API key'); + + // Auto-sync server users and libraries in background + syncServer(serverId, { syncUsers: true, syncLibraries: true }) + .then((result) => { + app.log.info({ serverId, usersAdded: result.usersAdded, librariesSynced: result.librariesSynced }, 'Auto-sync completed for Emby server'); + }) + .catch((error) => { + app.log.error({ error, serverId }, 'Auto-sync failed for Emby server'); + }); + + // Return updated tokens with new server access + return generateTokens(app, authUser.userId, authUser.username, authUser.role); + } catch (error) { + app.log.error({ error }, 'Emby connect-api-key failed'); + return reply.internalServerError('Failed to connect Emby server'); + } + } + ); +}; diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts new file mode 100644 index 0000000..ae734ac --- /dev/null +++ b/apps/server/src/routes/auth/index.ts @@ -0,0 +1,45 @@ +/** + * Authentication Routes Module + * + * Orchestrates all auth-related routes and provides unified export. + * + * Auth Flow Options: + * 1. Local signup: POST /signup → Create account with username/password + * 2. Local login: POST /login (type=local) → Login with username/password + * 3. Plex OAuth: POST /login (type=plex) → Login/signup with Plex + * + * Server Connection (separate from auth): + * - POST /plex/connect → Connect a Plex server after login + * - POST /jellyfin/connect → Connect a Jellyfin server after login + * - POST /emby/connect → Connect an Emby server after login + * + * Session Management: + * - GET /me → Get current user info + * - POST /refresh → Refresh access token + * - POST /logout → Revoke refresh token + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { localRoutes } from './local.js'; +import { plexRoutes } from './plex.js'; +import { jellyfinRoutes } from './jellyfin.js'; +import { embyRoutes } from './emby.js'; +import { sessionRoutes } from './session.js'; + +export const authRoutes: FastifyPluginAsync = async (app) => { + // Register all sub-route plugins + // Each plugin defines its own paths (no additional prefix needed) + await app.register(localRoutes); + await app.register(plexRoutes); + await app.register(jellyfinRoutes); + await app.register(embyRoutes); + await app.register(sessionRoutes); +}; + +// Re-export utilities for potential use by other modules +export { + generateTokens, + generateRefreshToken, + hashRefreshToken, + getAllServerIds, +} from './utils.js'; diff --git a/apps/server/src/routes/auth/jellyfin.ts b/apps/server/src/routes/auth/jellyfin.ts new file mode 100644 index 0000000..edb4e95 --- /dev/null +++ b/apps/server/src/routes/auth/jellyfin.ts @@ -0,0 +1,190 @@ +/** + * Jellyfin Authentication Routes + * + * POST /jellyfin/login - Login with Jellyfin username/password (checks all configured servers) + * POST /jellyfin/connect-api-key - Connect a Jellyfin server with API key (requires authentication) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../../db/client.js'; +import { servers, users } from '../../db/schema.js'; +import { JellyfinClient } from '../../services/mediaServer/index.js'; +// Token encryption removed - tokens now stored in plain text (DB is localhost-only) +import { generateTokens } from './utils.js'; +import { syncServer } from '../../services/sync.js'; +import { getUserByUsername, createUser } from '../../services/userService.js'; + +// Schema for Jellyfin login +const jellyfinLoginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +// Schema for API key connection +const jellyfinConnectApiKeySchema = z.object({ + serverUrl: z.url(), + serverName: z.string().min(1).max(100), + apiKey: z.string().min(1), +}); + +export const jellyfinRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /jellyfin/login - Login with Jellyfin username/password + * + * Checks all configured Jellyfin servers and authenticates if user is admin on any server. + * Creates a new user with 'admin' role if user doesn't exist. + */ + app.post('/jellyfin/login', async (request, reply) => { + const body = jellyfinLoginSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Username and password are required'); + } + + const { username, password } = body.data; + + try { + // Get all configured Jellyfin servers + const jellyfinServers = await db + .select() + .from(servers) + .where(eq(servers.type, 'jellyfin')); + + if (jellyfinServers.length === 0) { + return reply.unauthorized('No Jellyfin servers configured. Please add a server first.'); + } + + // Try to authenticate with each server + for (const server of jellyfinServers) { + try { + const authResult = await JellyfinClient.authenticate(server.url, username, password); + + if (authResult?.isAdmin) { + // User is admin on this server - proceed with login + app.log.info({ username, serverId: server.id }, 'Jellyfin admin authentication successful'); + + // Check if user already exists + let user = await getUserByUsername(username); + + if (!user) { + // Create new user with admin role + user = await createUser({ + username, + role: 'admin', + email: undefined, // Jellyfin doesn't expose email in auth response + thumbnail: undefined, // Can be populated later via sync + }); + app.log.info({ userId: user.id, username }, 'Created new user from Jellyfin admin login'); + } else { + // Update existing user role to admin if not already + if (user.role !== 'admin' && user.role !== 'owner') { + await db + .update(users) + .set({ role: 'admin', updatedAt: new Date() }) + .where(eq(users.id, user.id)); + user.role = 'admin'; + app.log.info({ userId: user.id, username }, 'Updated user role to admin from Jellyfin login'); + } + } + + // Generate and return tokens + return generateTokens(app, user.id, user.username, user.role); + } + } catch (error) { + // Authentication failed on this server, try next one + app.log.debug({ error, serverId: server.id, username }, 'Jellyfin authentication failed on server'); + continue; + } + } + + // Authentication failed on all servers or user is not admin + app.log.warn({ username }, 'Jellyfin login failed: invalid credentials or not admin'); + return reply.unauthorized('Invalid username or password, or user is not an administrator on any configured Jellyfin server'); + } catch (error) { + app.log.error({ error, username }, 'Jellyfin login error'); + return reply.internalServerError('Failed to authenticate with Jellyfin servers'); + } + }); + + /** + * POST /jellyfin/connect-api-key - Connect a Jellyfin server with API key (requires authentication) + */ + app.post( + '/jellyfin/connect-api-key', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = jellyfinConnectApiKeySchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('serverUrl, serverName, and apiKey are required'); + } + + const authUser = request.user; + + // Only owners can add servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only owners can add servers'); + } + + const { serverUrl, serverName, apiKey } = body.data; + + try { + // Verify the API key has admin access + const isAdmin = await JellyfinClient.verifyServerAdmin(apiKey, serverUrl); + + if (!isAdmin) { + return reply.forbidden('API key does not have administrator access to this Jellyfin server'); + } + + // Create or update server + let server = await db + .select() + .from(servers) + .where(and(eq(servers.url, serverUrl), eq(servers.type, 'jellyfin'))) + .limit(1); + + if (server.length === 0) { + const inserted = await db + .insert(servers) + .values({ + name: serverName, + type: 'jellyfin', + url: serverUrl, + token: apiKey, + }) + .returning(); + server = inserted; + } else { + const existingServer = server[0]!; + await db + .update(servers) + .set({ + name: serverName, + token: apiKey, + updatedAt: new Date(), + }) + .where(eq(servers.id, existingServer.id)); + } + + const serverId = server[0]!.id; + + app.log.info({ userId: authUser.userId, serverId }, 'Jellyfin server connected via API key'); + + // Auto-sync server users and libraries in background + syncServer(serverId, { syncUsers: true, syncLibraries: true }) + .then((result) => { + app.log.info({ serverId, usersAdded: result.usersAdded, librariesSynced: result.librariesSynced }, 'Auto-sync completed for Jellyfin server'); + }) + .catch((error) => { + app.log.error({ error, serverId }, 'Auto-sync failed for Jellyfin server'); + }); + + // Return updated tokens with new server access + return generateTokens(app, authUser.userId, authUser.username, authUser.role); + } catch (error) { + app.log.error({ error }, 'Jellyfin connect-api-key failed'); + return reply.internalServerError('Failed to connect Jellyfin server'); + } + } + ); +}; diff --git a/apps/server/src/routes/auth/local.ts b/apps/server/src/routes/auth/local.ts new file mode 100644 index 0000000..1792ae9 --- /dev/null +++ b/apps/server/src/routes/auth/local.ts @@ -0,0 +1,137 @@ +/** + * Local Authentication Routes + * + * POST /signup - Create a local account + * POST /login - Login with local credentials or initiate Plex OAuth + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and, isNotNull } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../../db/client.js'; +import { users } from '../../db/schema.js'; +import { PlexClient } from '../../services/mediaServer/index.js'; +import { hashPassword, verifyPassword } from '../../utils/password.js'; +import { generateTokens } from './utils.js'; +import { getUserByEmail, getOwnerUser } from '../../services/userService.js'; + +// Schemas +const signupSchema = z.object({ + username: z.string().min(3).max(50), // Display name + email: z.email(), + password: z.string().min(8).max(100), +}); + +const localLoginSchema = z.object({ + type: z.literal('local'), + email: z.email(), + password: z.string().min(1), +}); + +const plexLoginSchema = z.object({ + type: z.literal('plex'), + forwardUrl: z.url().optional(), +}); + +// Note: Jellyfin login is handled at /auth/jellyfin/login, not here +const loginSchema = z.discriminatedUnion('type', [localLoginSchema, plexLoginSchema]); + +export const localRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /signup - Create a local account + */ + app.post('/signup', async (request, reply) => { + const body = signupSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid signup data: email, username (3-50 chars), password (8+ chars) required'); + } + + const { username, email, password } = body.data; + + // Check if email already exists + const existing = await getUserByEmail(email); + if (existing) { + return reply.conflict('Email already registered'); + } + + // Check if this is the first user (will be owner) + const owner = await getOwnerUser(); + const isFirstUser = !owner; + + // Create user with password hash + // First user becomes owner, subsequent users are viewers + const passwordHashValue = await hashPassword(password); + const role = isFirstUser ? 'owner' : 'viewer'; + + const [newUser] = await db + .insert(users) + .values({ + username, + email, + passwordHash: passwordHashValue, + role, + }) + .returning(); + + if (!newUser) { + return reply.internalServerError('Failed to create user'); + } + + app.log.info({ userId: newUser.id, role }, 'Local account created'); + + return generateTokens(app, newUser.id, newUser.username, newUser.role); + }); + + /** + * POST /login - Login with local credentials or initiate Plex OAuth + */ + app.post('/login', async (request, reply) => { + const body = loginSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid login request'); + } + + const { type } = body.data; + + if (type === 'local') { + const { email, password } = body.data; + + // Find user by email with password hash + const userRows = await db + .select() + .from(users) + .where(and(eq(users.email, email), isNotNull(users.passwordHash))) + .limit(1); + + const user = userRows[0]; + if (!user?.passwordHash) { + return reply.unauthorized('Invalid email or password'); + } + + // Verify password + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + return reply.unauthorized('Invalid email or password'); + } + + app.log.info({ userId: user.id }, 'Local login successful'); + + return generateTokens(app, user.id, user.username, user.role); + } + + if (type === 'plex') { + // Plex OAuth - initiate flow + try { + const forwardUrl = body.data.forwardUrl; + const { pinId, authUrl } = await PlexClient.initiateOAuth(forwardUrl); + return { pinId, authUrl }; + } catch (error) { + app.log.error({ error }, 'Failed to initiate Plex OAuth'); + return reply.internalServerError('Failed to initiate Plex authentication'); + } + } + + // This should not be reached due to discriminated union, but handle gracefully + return reply.badRequest('Invalid login type'); + }); +}; diff --git a/apps/server/src/routes/auth/plex.ts b/apps/server/src/routes/auth/plex.ts new file mode 100644 index 0000000..b185b42 --- /dev/null +++ b/apps/server/src/routes/auth/plex.ts @@ -0,0 +1,552 @@ +/** + * Plex Authentication Routes + * + * POST /plex/check-pin - Check Plex PIN status + * POST /plex/connect - Complete Plex signup and connect a server + * GET /plex/available-servers - Discover available Plex servers for adding + * POST /plex/add-server - Add an additional Plex server + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import type { PlexAvailableServersResponse, PlexDiscoveredServer, PlexDiscoveredConnection } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { servers, users, serverUsers } from '../../db/schema.js'; +import { PlexClient } from '../../services/mediaServer/index.js'; +// Token encryption removed - tokens now stored in plain text (DB is localhost-only) +import { plexHeaders } from '../../utils/http.js'; +import { + generateTokens, + generateTempToken, + PLEX_TEMP_TOKEN_PREFIX, + PLEX_TEMP_TOKEN_TTL, +} from './utils.js'; +import { syncServer } from '../../services/sync.js'; +import { getUserByPlexAccountId, getOwnerUser, getUserById } from '../../services/userService.js'; + +// Schemas +const plexCheckPinSchema = z.object({ + pinId: z.string(), +}); + +const plexConnectSchema = z.object({ + tempToken: z.string(), + serverUri: z.url(), + serverName: z.string().min(1).max(100), + clientIdentifier: z.string().optional(), // For storing machineIdentifier +}); + +const plexAddServerSchema = z.object({ + serverUri: z.url(), + serverName: z.string().min(1).max(100), + clientIdentifier: z.string().min(1), // Required for dedup +}); + +// Connection testing timeout in milliseconds +const CONNECTION_TEST_TIMEOUT = 3000; + +export const plexRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /plex/check-pin - Check Plex PIN status + * + * Returns: + * - { authorized: false } if PIN not yet claimed + * - { authorized: true, accessToken, refreshToken, user } if user found by plexAccountId + * - { authorized: true, needsServerSelection: true, servers, tempToken } if new Plex user + */ + app.post('/plex/check-pin', async (request, reply) => { + const body = plexCheckPinSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('pinId is required'); + } + + const { pinId } = body.data; + + try { + const authResult = await PlexClient.checkOAuthPin(pinId); + + if (!authResult) { + return { authorized: false, message: 'PIN not yet authorized' }; + } + + // Check if user exists by Plex account ID (global Plex.tv ID) + let existingUser = await getUserByPlexAccountId(authResult.id); + + // Fallback: Check by externalId in server_users (server-synced users may have Plex ID there) + if (!existingUser) { + const fallbackServerUsers = await db + .select({ userId: serverUsers.userId }) + .from(serverUsers) + .where(eq(serverUsers.externalId, authResult.id)) + .limit(1); + if (fallbackServerUsers[0]) { + existingUser = await getUserById(fallbackServerUsers[0].userId); + } + } + + if (existingUser) { + // Returning Plex user - update their info and link plex_account_id + const user = existingUser; + + await db + .update(users) + .set({ + username: authResult.username, + email: authResult.email, + thumbnail: authResult.thumb, + plexAccountId: authResult.id, // Link the Plex account ID + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)); + + app.log.info({ userId: user.id }, 'Returning Plex user login'); + + return { + authorized: true, + ...(await generateTokens(app, user.id, authResult.username, user.role)), + }; + } + + // New Plex user - check if they own any servers + const plexServers = await PlexClient.getServers(authResult.token); + + // Check if this is the first owner + const owner = await getOwnerUser(); + const isFirstUser = !owner; + + // Store temp token for completing registration + const tempToken = generateTempToken(); + await app.redis.setex( + `${PLEX_TEMP_TOKEN_PREFIX}${tempToken}`, + PLEX_TEMP_TOKEN_TTL, + JSON.stringify({ + plexAccountId: authResult.id, + plexUsername: authResult.username, + plexEmail: authResult.email, + plexThumb: authResult.thumb, + plexToken: authResult.token, + isFirstUser, + }) + ); + + // If they have servers, let them select one to connect + if (plexServers.length > 0) { + const formattedServers = plexServers.map((s) => ({ + name: s.name, + platform: s.platform, + version: s.productVersion, + clientIdentifier: s.clientIdentifier, // For storing machineIdentifier + connections: s.connections.map((c) => ({ + uri: c.uri, + local: c.local, + address: c.address, + port: c.port, + })), + })); + + return { + authorized: true, + needsServerSelection: true, + servers: formattedServers, + tempToken, + }; + } + + // No servers - create account without server connection + // First user becomes owner, subsequent users are viewers + const role = isFirstUser ? 'owner' : 'viewer'; + + const [newUser] = await db + .insert(users) + .values({ + username: authResult.username, + email: authResult.email, + thumbnail: authResult.thumb, + plexAccountId: authResult.id, + role, + }) + .returning(); + + if (!newUser) { + return reply.internalServerError('Failed to create user'); + } + + // Clean up temp token + await app.redis.del(`${PLEX_TEMP_TOKEN_PREFIX}${tempToken}`); + + app.log.info({ userId: newUser.id, role }, 'New Plex user created (no servers)'); + + return { + authorized: true, + ...(await generateTokens(app, newUser.id, newUser.username, newUser.role)), + }; + } catch (error) { + app.log.error({ error }, 'Plex check-pin failed'); + return reply.internalServerError('Failed to check Plex authorization'); + } + }); + + /** + * POST /plex/connect - Complete Plex signup and connect a server + */ + app.post('/plex/connect', async (request, reply) => { + const body = plexConnectSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('tempToken, serverUri, and serverName are required'); + } + + const { tempToken, serverUri, serverName, clientIdentifier } = body.data; + + // Get stored Plex auth from temp token + const stored = await app.redis.get(`${PLEX_TEMP_TOKEN_PREFIX}${tempToken}`); + if (!stored) { + return reply.unauthorized('Invalid or expired temp token. Please restart login.'); + } + + // Delete temp token (one-time use) + await app.redis.del(`${PLEX_TEMP_TOKEN_PREFIX}${tempToken}`); + + const { plexAccountId, plexUsername, plexEmail, plexThumb, plexToken, isFirstUser } = JSON.parse( + stored + ) as { + plexAccountId: string; + plexUsername: string; + plexEmail: string; + plexThumb: string; + plexToken: string; + isFirstUser: boolean; + }; + + try { + // Verify user is admin on the selected server + const isAdmin = await PlexClient.verifyServerAdmin(plexToken, serverUri); + if (!isAdmin) { + return reply.forbidden('You must be an admin on the selected Plex server'); + } + + // Create or update server + let server = await db + .select() + .from(servers) + .where(and(eq(servers.url, serverUri), eq(servers.type, 'plex'))) + .limit(1); + + if (server.length === 0) { + const inserted = await db + .insert(servers) + .values({ + name: serverName, + type: 'plex', + url: serverUri, + token: plexToken, + machineIdentifier: clientIdentifier, + }) + .returning(); + server = inserted; + } else { + const existingServer = server[0]!; + await db + .update(servers) + .set({ + token: plexToken, + updatedAt: new Date(), + // Update machineIdentifier if not already set + ...(clientIdentifier && !existingServer.machineIdentifier + ? { machineIdentifier: clientIdentifier } + : {}), + }) + .where(eq(servers.id, existingServer.id)); + } + + const serverId = server[0]!.id; + + // Create user identity (no serverId on users table) + // First user becomes owner, subsequent users are viewers + const role = isFirstUser ? 'owner' : 'viewer'; + + const [newUser] = await db + .insert(users) + .values({ + username: plexUsername, + email: plexEmail, + thumbnail: plexThumb, + plexAccountId: plexAccountId, + role, + }) + .returning(); + + if (!newUser) { + return reply.internalServerError('Failed to create user'); + } + + // Create server_user linking the identity to this server + await db.insert(serverUsers).values({ + userId: newUser.id, + serverId, + externalId: plexAccountId, + username: plexUsername, + email: plexEmail, + thumbUrl: plexThumb, + isServerAdmin: true, // They verified as admin + }); + + app.log.info({ userId: newUser.id, serverId, role }, 'New Plex user with server created'); + + // Auto-sync server users and libraries in background + syncServer(serverId, { syncUsers: true, syncLibraries: true }) + .then((result) => { + app.log.info({ serverId, usersAdded: result.usersAdded, librariesSynced: result.librariesSynced }, 'Auto-sync completed for Plex server'); + }) + .catch((error) => { + app.log.error({ error, serverId }, 'Auto-sync failed for Plex server'); + }); + + return generateTokens(app, newUser.id, newUser.username, newUser.role); + } catch (error) { + app.log.error({ error }, 'Plex connect failed'); + return reply.internalServerError('Failed to connect to Plex server'); + } + }); + + /** + * GET /plex/available-servers - Discover available Plex servers for adding + * + * Requires authentication and owner role. + * Returns list of user's owned Plex servers that aren't already connected, + * with connection testing results. + */ + app.get( + '/plex/available-servers', + { preHandler: [app.authenticate] }, + async (request, reply): Promise => { + const authUser = request.user; + + // Only owners can add servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can add servers'); + } + + // Get existing Plex servers to find a token + const existingPlexServers = await db + .select({ + id: servers.id, + token: servers.token, + machineIdentifier: servers.machineIdentifier, + }) + .from(servers) + .where(eq(servers.type, 'plex')); + + if (existingPlexServers.length === 0) { + // No Plex servers connected - user needs to link their Plex account + return { servers: [], hasPlexToken: false }; + } + + // Use the first server's token to query plex.tv + const plexToken = existingPlexServers[0]!.token; + + // Get all servers the user owns from plex.tv + let allServers; + try { + allServers = await PlexClient.getServers(plexToken); + } catch (error) { + app.log.error({ error }, 'Failed to fetch servers from plex.tv'); + return reply.internalServerError('Failed to fetch servers from Plex'); + } + + // Get list of already-connected machine identifiers + const connectedMachineIds = new Set( + existingPlexServers + .map((s) => s.machineIdentifier) + .filter((id): id is string => id !== null) + ); + + // Filter out already-connected servers + const availableServers = allServers.filter( + (s) => !connectedMachineIds.has(s.clientIdentifier) + ); + + if (availableServers.length === 0) { + return { servers: [], hasPlexToken: true }; + } + + // Test connections for each server in parallel + const testedServers: PlexDiscoveredServer[] = await Promise.all( + availableServers.map(async (server) => { + // Test all connections in parallel + const connectionResults = await Promise.all( + server.connections.map(async (conn): Promise => { + const start = Date.now(); + try { + const response = await fetch(`${conn.uri}/`, { + headers: plexHeaders(plexToken), + signal: AbortSignal.timeout(CONNECTION_TEST_TIMEOUT), + }); + if (response.ok) { + return { + uri: conn.uri, + local: conn.local, + address: conn.address, + port: conn.port, + reachable: true, + latencyMs: Date.now() - start, + }; + } + } catch { + // Connection failed or timed out + } + return { + uri: conn.uri, + local: conn.local, + address: conn.address, + port: conn.port, + reachable: false, + latencyMs: null, + }; + }) + ); + + // Sort connections: reachable first, then by local preference, then by latency + const sortedConnections = connectionResults.sort((a, b) => { + // Reachable first + if (a.reachable !== b.reachable) return a.reachable ? -1 : 1; + // Then local preference (local before remote) + if (a.local !== b.local) return a.local ? -1 : 1; + // Then by latency (lower is better) + if (a.latencyMs !== null && b.latencyMs !== null) { + return a.latencyMs - b.latencyMs; + } + return 0; + }); + + // Pick the best connection as recommended + const recommended = sortedConnections.find((c) => c.reachable); + + return { + name: server.name, + platform: server.platform, + version: server.productVersion, + clientIdentifier: server.clientIdentifier, + recommendedUri: recommended?.uri ?? null, + connections: sortedConnections, + }; + }) + ); + + return { servers: testedServers, hasPlexToken: true }; + } + ); + + /** + * POST /plex/add-server - Add an additional Plex server + * + * Requires authentication and owner role. + * Uses existing Plex token from another connected server. + */ + app.post( + '/plex/add-server', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = plexAddServerSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('serverUri, serverName, and clientIdentifier are required'); + } + + const { serverUri, serverName, clientIdentifier } = body.data; + const authUser = request.user; + + // Only owners can add servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can add servers'); + } + + // Get existing Plex server to retrieve token + const existingPlexServer = await db + .select({ token: servers.token }) + .from(servers) + .where(eq(servers.type, 'plex')) + .limit(1); + + if (existingPlexServer.length === 0) { + return reply.badRequest('No Plex servers connected. Please link your Plex account first.'); + } + + const plexToken = existingPlexServer[0]!.token; + + // Check if server already exists (by machineIdentifier or URL) + const existing = await db + .select({ id: servers.id }) + .from(servers) + .where( + eq(servers.machineIdentifier, clientIdentifier) + ) + .limit(1); + + if (existing.length > 0) { + return reply.conflict('This server is already connected'); + } + + // Also check by URL + const existingByUrl = await db + .select({ id: servers.id }) + .from(servers) + .where(eq(servers.url, serverUri)) + .limit(1); + + if (existingByUrl.length > 0) { + return reply.conflict('A server with this URL is already connected'); + } + + try { + // Verify admin access on the new server + const isAdmin = await PlexClient.verifyServerAdmin(plexToken, serverUri); + if (!isAdmin) { + return reply.forbidden('You must be an admin on the selected Plex server'); + } + + // Create server record + const [newServer] = await db + .insert(servers) + .values({ + name: serverName, + type: 'plex', + url: serverUri, + token: plexToken, + machineIdentifier: clientIdentifier, + }) + .returning(); + + if (!newServer) { + return reply.internalServerError('Failed to create server'); + } + + app.log.info({ serverId: newServer.id, serverName }, 'Additional Plex server added'); + + // Auto-sync server users and libraries in background + syncServer(newServer.id, { syncUsers: true, syncLibraries: true }) + .then((result) => { + app.log.info( + { serverId: newServer.id, usersAdded: result.usersAdded, librariesSynced: result.librariesSynced }, + 'Auto-sync completed for new Plex server' + ); + }) + .catch((error) => { + app.log.error({ error, serverId: newServer.id }, 'Auto-sync failed for new Plex server'); + }); + + return { + success: true, + server: { + id: newServer.id, + name: newServer.name, + type: newServer.type, + url: newServer.url, + }, + }; + } catch (error) { + app.log.error({ error }, 'Failed to add Plex server'); + return reply.internalServerError('Failed to add Plex server'); + } + } + ); +}; diff --git a/apps/server/src/routes/auth/session.ts b/apps/server/src/routes/auth/session.ts new file mode 100644 index 0000000..550aaf3 --- /dev/null +++ b/apps/server/src/routes/auth/session.ts @@ -0,0 +1,132 @@ +/** + * Session Management Routes + * + * POST /refresh - Refresh access token + * POST /logout - Revoke refresh token + * GET /me - Get current user info + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { JWT_CONFIG, canLogin, type AuthUser } from '@tracearr/shared'; +import { + generateRefreshToken, + hashRefreshToken, + getAllServerIds, + REFRESH_TOKEN_PREFIX, + REFRESH_TOKEN_TTL, +} from './utils.js'; +import { getUserById } from '../../services/userService.js'; + +// Schema +const refreshSchema = z.object({ + refreshToken: z.string(), +}); + +export const sessionRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /refresh - Refresh access token + */ + app.post('/refresh', async (request, reply) => { + const body = refreshSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { refreshToken } = body.data; + const refreshTokenHash = hashRefreshToken(refreshToken); + + const stored = await app.redis.get(`${REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); + if (!stored) { + return reply.unauthorized('Invalid or expired refresh token'); + } + + const { userId } = JSON.parse(stored) as { userId: string; serverIds: string[] }; + + const user = await getUserById(userId); + + if (!user) { + await app.redis.del(`${REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); + return reply.unauthorized('User not found'); + } + + // Check if user can still log in + if (!canLogin(user.role)) { + await app.redis.del(`${REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); + return reply.unauthorized('Account is not active'); + } + + // Get fresh server IDs (in case servers were added/removed) + // TODO: Admins should get servers where they're isServerAdmin=true + const serverIds = user.role === 'owner' ? await getAllServerIds() : []; + + const accessPayload: AuthUser = { + userId, + username: user.username, + role: user.role, + serverIds, + }; + + const accessToken = app.jwt.sign(accessPayload, { + expiresIn: JWT_CONFIG.ACCESS_TOKEN_EXPIRY, + }); + + // Rotate refresh token + const newRefreshToken = generateRefreshToken(); + const newRefreshTokenHash = hashRefreshToken(newRefreshToken); + + await app.redis.del(`${REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); + await app.redis.setex( + `${REFRESH_TOKEN_PREFIX}${newRefreshTokenHash}`, + REFRESH_TOKEN_TTL, + JSON.stringify({ userId, serverIds }) + ); + + return { accessToken, refreshToken: newRefreshToken }; + }); + + /** + * POST /logout - Revoke refresh token + */ + app.post('/logout', { preHandler: [app.authenticate] }, async (request, reply) => { + const body = refreshSchema.safeParse(request.body); + + if (body.success) { + const { refreshToken } = body.data; + await app.redis.del(`${REFRESH_TOKEN_PREFIX}${hashRefreshToken(refreshToken)}`); + } + + reply.clearCookie('token'); + return { success: true }; + }); + + /** + * GET /me - Get current user info + */ + app.get('/me', { preHandler: [app.authenticate] }, async (request) => { + const authUser = request.user; + + const user = await getUserById(authUser.userId); + + if (!user) { + // User in JWT doesn't exist in database - token is invalid + throw app.httpErrors.unauthorized('User no longer exists'); + } + + // Get fresh server IDs + // TODO: Admins should get servers where they're isServerAdmin=true + const serverIds = user.role === 'owner' ? await getAllServerIds() : []; + + return { + userId: user.id, + username: user.username, + email: user.email, + thumbnail: user.thumbnail, + role: user.role, + aggregateTrustScore: user.aggregateTrustScore, + serverIds, + hasPassword: !!user.passwordHash, + hasPlexLinked: !!user.plexAccountId, + }; + }); +}; diff --git a/apps/server/src/routes/auth/utils.ts b/apps/server/src/routes/auth/utils.ts new file mode 100644 index 0000000..78cbc0d --- /dev/null +++ b/apps/server/src/routes/auth/utils.ts @@ -0,0 +1,84 @@ +/** + * Auth Route Utilities + * + * Shared helpers for authentication routes including token generation, + * hashing, and Redis key management. + */ + +import { createHash, randomBytes } from 'crypto'; +import type { FastifyInstance } from 'fastify'; +import { JWT_CONFIG, type AuthUser, type UserRole } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { servers } from '../../db/schema.js'; + +// Redis key prefixes +export const REFRESH_TOKEN_PREFIX = 'tracearr:refresh:'; +export const PLEX_TEMP_TOKEN_PREFIX = 'tracearr:plex_temp:'; +export const REFRESH_TOKEN_TTL = 30 * 24 * 60 * 60; // 30 days +export const PLEX_TEMP_TOKEN_TTL = 10 * 60; // 10 minutes for server selection + +/** + * Generate a random refresh token + */ +export function generateRefreshToken(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Hash a refresh token for secure storage + */ +export function hashRefreshToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Generate a temporary token for Plex OAuth flow + */ +export function generateTempToken(): string { + return randomBytes(24).toString('hex'); +} + +/** + * Get all server IDs for owner tokens + */ +export async function getAllServerIds(): Promise { + const allServers = await db.select({ id: servers.id }).from(servers); + return allServers.map((s) => s.id); +} + +/** + * Generate access and refresh tokens for a user + * Note: Caller must verify canLogin(role) before calling this function + */ +export async function generateTokens( + app: FastifyInstance, + userId: string, + username: string, + role: UserRole +) { + // Owners get access to ALL servers + // TODO: Admins should get servers where they're isServerAdmin=true + const serverIds = role === 'owner' ? await getAllServerIds() : []; + + const accessPayload: AuthUser = { + userId, + username, + role, + serverIds, + }; + + const accessToken = app.jwt.sign(accessPayload, { + expiresIn: JWT_CONFIG.ACCESS_TOKEN_EXPIRY, + }); + + const refreshToken = generateRefreshToken(); + const refreshTokenHash = hashRefreshToken(refreshToken); + + await app.redis.setex( + `${REFRESH_TOKEN_PREFIX}${refreshTokenHash}`, + REFRESH_TOKEN_TTL, + JSON.stringify({ userId, serverIds }) + ); + + return { accessToken, refreshToken, user: accessPayload }; +} diff --git a/apps/server/src/routes/channelRouting.ts b/apps/server/src/routes/channelRouting.ts new file mode 100644 index 0000000..9051691 --- /dev/null +++ b/apps/server/src/routes/channelRouting.ts @@ -0,0 +1,256 @@ +/** + * Notification Channel Routing routes - Controls which channels receive which events + * + * Web admin endpoints: + * - GET /settings/notifications/routing - Get all routing configuration + * - PATCH /settings/notifications/routing/:eventType - Update routing for specific event + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import type { NotificationChannelRouting, NotificationEventType } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { notificationChannelRouting, notificationEventTypeEnum } from '../db/schema.js'; + +// Valid event types for validation +const validEventTypes = notificationEventTypeEnum as readonly string[]; + +// Update routing schema +const updateRoutingSchema = z.object({ + discordEnabled: z.boolean().optional(), + webhookEnabled: z.boolean().optional(), + pushEnabled: z.boolean().optional(), + webToastEnabled: z.boolean().optional(), +}); + +/** + * Transform DB row to API response + */ +function toApiResponse( + row: typeof notificationChannelRouting.$inferSelect +): NotificationChannelRouting { + return { + id: row.id, + eventType: row.eventType, + discordEnabled: row.discordEnabled, + webhookEnabled: row.webhookEnabled, + pushEnabled: row.pushEnabled, + webToastEnabled: row.webToastEnabled, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export const channelRoutingRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /settings/notifications/routing - Get all routing configuration + * + * Requires owner authentication. Returns routing configuration for all event types. + */ + app.get('/routing', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + // Only owners can view routing settings + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can view notification routing'); + } + + // Get all routing configuration + const rows = await db.select().from(notificationChannelRouting).orderBy(notificationChannelRouting.eventType); + + // If no rows exist (shouldn't happen due to seed), create defaults + if (rows.length === 0) { + const defaultRouting = notificationEventTypeEnum.map((eventType) => ({ + eventType, + discordEnabled: !['stream_started', 'stream_stopped', 'trust_score_changed'].includes(eventType), + webhookEnabled: !['stream_started', 'stream_stopped', 'trust_score_changed'].includes(eventType), + pushEnabled: !['stream_started', 'stream_stopped', 'trust_score_changed'].includes(eventType), + webToastEnabled: !['stream_started', 'stream_stopped', 'trust_score_changed'].includes(eventType), + })); + + const inserted = await db + .insert(notificationChannelRouting) + .values(defaultRouting) + .returning(); + + return inserted.map(toApiResponse); + } + + return rows.map(toApiResponse); + }); + + /** + * PATCH /settings/notifications/routing/:eventType - Update routing for specific event + * + * Requires owner authentication. Updates channel routing for a specific event type. + */ + app.patch<{ Params: { eventType: string } }>( + '/routing/:eventType', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { eventType } = request.params; + + // Validate event type + if (!validEventTypes.includes(eventType)) { + return reply.badRequest(`Invalid event type: ${eventType}`); + } + + const body = updateRoutingSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const authUser = request.user; + + // Only owners can update routing settings + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can update notification routing'); + } + + // Find existing routing + const existing = await db + .select() + .from(notificationChannelRouting) + .where(eq(notificationChannelRouting.eventType, eventType as NotificationEventType)) + .limit(1); + + let routingId: string; + + if (existing.length === 0) { + // Create new routing record + const inserted = await db + .insert(notificationChannelRouting) + .values({ + eventType: eventType as NotificationEventType, + discordEnabled: body.data.discordEnabled ?? true, + webhookEnabled: body.data.webhookEnabled ?? true, + pushEnabled: body.data.pushEnabled ?? true, + webToastEnabled: body.data.webToastEnabled ?? true, + }) + .returning(); + + if (!inserted[0]) { + return reply.internalServerError('Failed to create routing configuration'); + } + + routingId = inserted[0].id; + } else { + routingId = existing[0]!.id; + + // Build update object + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (body.data.discordEnabled !== undefined) { + updateData.discordEnabled = body.data.discordEnabled; + } + if (body.data.webhookEnabled !== undefined) { + updateData.webhookEnabled = body.data.webhookEnabled; + } + if (body.data.pushEnabled !== undefined) { + updateData.pushEnabled = body.data.pushEnabled; + } + if (body.data.webToastEnabled !== undefined) { + updateData.webToastEnabled = body.data.webToastEnabled; + } + + await db + .update(notificationChannelRouting) + .set(updateData) + .where(eq(notificationChannelRouting.id, routingId)); + } + + // Return updated routing + const updated = await db + .select() + .from(notificationChannelRouting) + .where(eq(notificationChannelRouting.id, routingId)) + .limit(1); + + const row = updated[0]; + if (!row) { + return reply.internalServerError('Failed to update routing configuration'); + } + + app.log.info( + { userId: authUser.userId, eventType }, + 'Notification channel routing updated' + ); + + return toApiResponse(row); + } + ); +}; + +/** + * Channel routing for a specific event type (internal use by notification services) + */ +export interface ChannelRoutingConfig { + discordEnabled: boolean; + webhookEnabled: boolean; + pushEnabled: boolean; + webToastEnabled: boolean; +} + +/** + * Get channel routing for a specific event type (internal use) + */ +export async function getChannelRouting( + eventType: NotificationEventType +): Promise { + const row = await db + .select({ + discordEnabled: notificationChannelRouting.discordEnabled, + webhookEnabled: notificationChannelRouting.webhookEnabled, + pushEnabled: notificationChannelRouting.pushEnabled, + webToastEnabled: notificationChannelRouting.webToastEnabled, + }) + .from(notificationChannelRouting) + .where(eq(notificationChannelRouting.eventType, eventType)) + .limit(1); + + const routing = row[0]; + if (!routing) { + // Return defaults if no routing exists + // Most events default to enabled, except stream started/stopped + const isLowPriorityEvent = ['stream_started', 'stream_stopped', 'trust_score_changed'].includes(eventType); + return { + discordEnabled: !isLowPriorityEvent, + webhookEnabled: !isLowPriorityEvent, + pushEnabled: !isLowPriorityEvent, + webToastEnabled: !isLowPriorityEvent, + }; + } + + return routing; +} + +/** + * Get all channel routing configuration (internal use for caching) + */ +export async function getAllChannelRouting(): Promise> { + const rows = await db + .select({ + eventType: notificationChannelRouting.eventType, + discordEnabled: notificationChannelRouting.discordEnabled, + webhookEnabled: notificationChannelRouting.webhookEnabled, + pushEnabled: notificationChannelRouting.pushEnabled, + webToastEnabled: notificationChannelRouting.webToastEnabled, + }) + .from(notificationChannelRouting); + + const routingMap = new Map(); + + for (const row of rows) { + routingMap.set(row.eventType, { + discordEnabled: row.discordEnabled, + webhookEnabled: row.webhookEnabled, + pushEnabled: row.pushEnabled, + webToastEnabled: row.webToastEnabled, + }); + } + + return routingMap; +} diff --git a/apps/server/src/routes/debug.security.test.ts b/apps/server/src/routes/debug.security.test.ts new file mode 100644 index 0000000..406ba0f --- /dev/null +++ b/apps/server/src/routes/debug.security.test.ts @@ -0,0 +1,275 @@ +/** + * Debug Routes Security Tests + * + * Ensures debug routes are properly protected and only accessible by owners. + * These routes can cause significant data loss, so security is critical. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { + createTestApp, + generateTestToken, + createOwnerPayload, + createViewerPayload, +} from '../test/helpers.js'; +import { debugRoutes } from './debug.js'; + +// Mock the database module +vi.mock('../db/client.js', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue([{ count: 0 }]), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([]), + }), + returning: vi.fn().mockResolvedValue([]), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + execute: vi.fn().mockResolvedValue({ rows: [{ size: '10 MB' }] }), + }, +})); + +vi.mock('../db/schema.js', () => ({ + sessions: { id: 'id' }, + violations: { id: 'id' }, + users: { id: 'id' }, + servers: { id: 'id' }, + rules: { id: 'id' }, + settings: { id: 'id' }, +})); + +describe('Debug Routes Security', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await createTestApp(); + + // Register debug routes + await app.register(debugRoutes, { prefix: '/api/v1/debug' }); + + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + // All debug endpoints that need testing + const debugEndpoints = [ + { method: 'GET', url: '/api/v1/debug/stats' }, + { method: 'DELETE', url: '/api/v1/debug/sessions' }, + { method: 'DELETE', url: '/api/v1/debug/violations' }, + { method: 'DELETE', url: '/api/v1/debug/users' }, + { method: 'DELETE', url: '/api/v1/debug/servers' }, + { method: 'DELETE', url: '/api/v1/debug/rules' }, + { method: 'POST', url: '/api/v1/debug/reset' }, + { method: 'POST', url: '/api/v1/debug/refresh-aggregates' }, + { method: 'GET', url: '/api/v1/debug/env' }, + ]; + + describe('Unauthenticated Access Prevention', () => { + it.each(debugEndpoints)( + 'should reject unauthenticated requests to $method $url', + async ({ method, url }) => { + const res = await app.inject({ method: method as any, url }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('Invalid or expired token'); + } + ); + }); + + describe('Guest User Access Prevention', () => { + it.each(debugEndpoints)( + 'should reject guest users on $method $url', + async ({ method, url }) => { + const guestToken = generateTestToken(app, createViewerPayload()); + + const res = await app.inject({ + method: method as any, + url, + headers: { Authorization: `Bearer ${guestToken}` }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().message).toContain('Owner access required'); + } + ); + }); + + describe('Owner Access Allowed', () => { + it.each(debugEndpoints)( + 'should allow owner access to $method $url', + async ({ method, url }) => { + const ownerToken = generateTestToken(app, createOwnerPayload()); + + const res = await app.inject({ + method: method as any, + url, + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + // Owner should not get 401 or 403 + expect(res.statusCode).not.toBe(401); + expect(res.statusCode).not.toBe(403); + // Should get 200 or 500 (500 possible due to mocked DB) + expect([200, 500]).toContain(res.statusCode); + } + ); + }); + + describe('Privilege Escalation Prevention', () => { + it('should not allow role manipulation to access debug routes', async () => { + // Start with a guest token + const guestPayload = createViewerPayload(); + const guestToken = generateTestToken(app, guestPayload); + + // Try to manipulate the token to have owner role + const parts = guestToken.split('.'); + const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()); + payload.role = 'owner'; // Try to escalate + const tamperedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`; + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/stats', + headers: { Authorization: `Bearer ${tamperedToken}` }, + }); + + // Should be rejected - either invalid token (401) or still guest (403) + expect([401, 403]).toContain(res.statusCode); + }); + + it('should not allow adding owner role to token claims', async () => { + // Create a token with an extra claim trying to grant owner + const payload = { + ...createViewerPayload(), + isOwner: true, // Extra claim that shouldn't work + admin: true, // Another attempt + }; + const token = generateTestToken(app, payload); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/stats', + headers: { Authorization: `Bearer ${token}` }, + }); + + // Role is still 'guest', so should be forbidden + expect(res.statusCode).toBe(403); + }); + }); + + describe('Expired Token Handling', () => { + it('should reject expired owner tokens on debug routes', async () => { + const ownerPayload = createOwnerPayload(); + // Create a manually crafted expired token (signature will be invalid too) + const validToken = generateTestToken(app, ownerPayload); + const parts = validToken.split('.'); + const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()); + payload.exp = Math.floor(Date.now() / 1000) - 3600; // Expired 1 hour ago + const expiredPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const expiredToken = `${parts[0]}.${expiredPayload}.${parts[2]}`; + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/stats', + headers: { Authorization: `Bearer ${expiredToken}` }, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('Invalid Token Formats', () => { + const invalidTokens = [ + '', + 'invalid', + 'not.a.jwt', + 'Bearer ', + 'eyJhbGciOiJub25lIn0.eyJyb2xlIjoib3duZXIifQ.', // alg:none attack + 'null', + 'undefined', + '{"role":"owner"}', + ]; + + it.each(invalidTokens)( + 'should reject invalid token format: %s', + async (invalidToken) => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/stats', + headers: { Authorization: `Bearer ${invalidToken}` }, + }); + + expect(res.statusCode).toBe(401); + } + ); + }); +}); + +describe('Debug Routes - Destructive Operation Safeguards', () => { + let app: FastifyInstance; + let ownerToken: string; + + beforeAll(async () => { + app = await createTestApp(); + await app.register(debugRoutes, { prefix: '/api/v1/debug' }); + await app.ready(); + + ownerToken = generateTestToken(app, createOwnerPayload()); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should not expose database credentials in /env', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/env', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + // Even if there's an error, check the format + if (res.statusCode === 200) { + const body = res.json(); + const envString = JSON.stringify(body); + + // Should not contain actual secrets + expect(envString).not.toContain('password'); + expect(envString).not.toMatch(/postgresql:\/\/[^:]+:[^@]+@/); // DB URL with password + expect(envString).not.toMatch(/redis:\/\/:[^@]+@/); // Redis URL with password + } + }); + + it('should return structured stats without exposing internals', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/debug/stats', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + // Check structure is correct even with mocked data + if (res.statusCode === 200) { + const body = res.json(); + + // Should have expected structure + expect(body).toHaveProperty('counts'); + expect(body).toHaveProperty('database'); + + // Should not leak internal paths + const bodyString = JSON.stringify(body); + expect(bodyString).not.toContain('/Users/'); + expect(bodyString).not.toContain('node_modules'); + } + }); +}); diff --git a/apps/server/src/routes/debug.ts b/apps/server/src/routes/debug.ts new file mode 100644 index 0000000..66c6e4b --- /dev/null +++ b/apps/server/src/routes/debug.ts @@ -0,0 +1,252 @@ +/** + * Debug routes - owner only + * + * Hidden utilities for development and troubleshooting. + * All routes require owner authentication. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { + sessions, + violations, + users, + servers, + serverUsers, + rules, + settings, + mobileTokens, + mobileSessions, + notificationPreferences, + notificationChannelRouting, + terminationLogs, +} from '../db/schema.js'; + +export const debugRoutes: FastifyPluginAsync = async (app) => { + // All debug routes require owner + app.addHook('preHandler', async (request, reply) => { + await app.authenticate(request, reply); + if (request.user?.role !== 'owner') { + return reply.forbidden('Owner access required'); + } + }); + + /** + * GET /debug/stats - Database statistics + */ + app.get('/stats', async () => { + const [ + sessionCount, + violationCount, + userCount, + serverCount, + ruleCount, + ] = await Promise.all([ + db.select({ count: sql`count(*)::int` }).from(sessions), + db.select({ count: sql`count(*)::int` }).from(violations), + db.select({ count: sql`count(*)::int` }).from(users), + db.select({ count: sql`count(*)::int` }).from(servers), + db.select({ count: sql`count(*)::int` }).from(rules), + ]); + + // Get database size + const dbSize = await db.execute(sql` + SELECT pg_size_pretty(pg_database_size(current_database())) as size + `); + + // Get table sizes + const tableSizes = await db.execute(sql` + SELECT + relname as table_name, + pg_size_pretty(pg_total_relation_size(relid)) as total_size + FROM pg_catalog.pg_statio_user_tables + ORDER BY pg_total_relation_size(relid) DESC + LIMIT 10 + `); + + return { + counts: { + sessions: sessionCount[0]?.count ?? 0, + violations: violationCount[0]?.count ?? 0, + users: userCount[0]?.count ?? 0, + servers: serverCount[0]?.count ?? 0, + rules: ruleCount[0]?.count ?? 0, + }, + database: { + size: (dbSize.rows[0] as { size: string })?.size ?? 'unknown', + tables: tableSizes.rows as { table_name: string; total_size: string }[], + }, + }; + }); + + /** + * DELETE /debug/sessions - Clear all sessions + */ + app.delete('/sessions', async () => { + // Delete violations first (FK constraint) + const violationsDeleted = await db.delete(violations).returning({ id: violations.id }); + const sessionsDeleted = await db.delete(sessions).returning({ id: sessions.id }); + + return { + success: true, + deleted: { + sessions: sessionsDeleted.length, + violations: violationsDeleted.length, + }, + }; + }); + + /** + * DELETE /debug/violations - Clear all violations + */ + app.delete('/violations', async () => { + const deleted = await db.delete(violations).returning({ id: violations.id }); + return { + success: true, + deleted: deleted.length, + }; + }); + + /** + * DELETE /debug/users - Clear all non-owner users + */ + app.delete('/users', async () => { + // Delete sessions and violations for non-owner users first + const nonOwnerUsers = await db + .select({ id: users.id }) + .from(users) + .where(sql`is_owner = false`); + + const userIds = nonOwnerUsers.map((u) => u.id); + + if (userIds.length === 0) { + return { success: true, deleted: 0 }; + } + + // Build explicit PostgreSQL array literal (Drizzle doesn't auto-convert JS arrays for ANY()) + const userIdArray = sql.raw(`ARRAY[${userIds.map(id => `'${id}'::uuid`).join(',')}]`); + + // Delete violations for these users + await db.delete(violations).where(sql`user_id = ANY(${userIdArray})`); + + // Delete sessions for these users + await db.delete(sessions).where(sql`user_id = ANY(${userIdArray})`); + + // Delete the users + const deleted = await db + .delete(users) + .where(sql`is_owner = false`) + .returning({ id: users.id }); + + return { + success: true, + deleted: deleted.length, + }; + }); + + /** + * DELETE /debug/servers - Clear all servers (cascades to users, sessions, violations) + */ + app.delete('/servers', async () => { + const deleted = await db.delete(servers).returning({ id: servers.id }); + return { + success: true, + deleted: deleted.length, + }; + }); + + /** + * DELETE /debug/rules - Clear all rules + */ + app.delete('/rules', async () => { + // Delete violations first (FK constraint) + await db.delete(violations); + const deleted = await db.delete(rules).returning({ id: rules.id }); + return { + success: true, + deleted: deleted.length, + }; + }); + + /** + * POST /debug/reset - Full factory reset (deletes everything including owner) + */ + app.post('/reset', async () => { + // Delete everything in order respecting FK constraints + // Start with tables that have FK dependencies on other tables + await db.delete(violations); + await db.delete(terminationLogs); + await db.delete(sessions); + await db.delete(rules); + await db.delete(notificationChannelRouting); + await db.delete(notificationPreferences); + await db.delete(mobileSessions); + await db.delete(mobileTokens); + await db.delete(serverUsers); + await db.delete(users); + await db.delete(servers); + + // Reset settings to defaults + await db + .update(settings) + .set({ + allowGuestAccess: false, + discordWebhookUrl: null, + customWebhookUrl: null, + pollerEnabled: true, + pollerIntervalMs: 15000, + tautulliUrl: null, + tautulliApiKey: null, + }) + .where(sql`id = 1`); + + return { + success: true, + message: 'Factory reset complete. Please set up Tracearr again.', + }; + }); + + /** + * POST /debug/refresh-aggregates - Refresh TimescaleDB continuous aggregates + */ + app.post('/refresh-aggregates', async () => { + try { + // Refresh all continuous aggregates + await db.execute(sql` + CALL refresh_continuous_aggregate('hourly_stats', NULL, NULL) + `); + await db.execute(sql` + CALL refresh_continuous_aggregate('daily_stats', NULL, NULL) + `); + return { success: true, message: 'Aggregates refreshed' }; + } catch { + // Aggregates might not exist yet + return { success: false, message: 'Aggregates not configured or refresh failed' }; + } + }); + + /** + * GET /debug/env - Safe environment info (no secrets) + */ + app.get('/env', async () => { + return { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + uptime: Math.round(process.uptime()), + memoryUsage: { + heapUsed: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)} MB`, + heapTotal: `${Math.round(process.memoryUsage().heapTotal / 1024 / 1024)} MB`, + rss: `${Math.round(process.memoryUsage().rss / 1024 / 1024)} MB`, + }, + env: { + NODE_ENV: process.env.NODE_ENV ?? 'development', + DATABASE_URL: process.env.DATABASE_URL ? '[set]' : '[not set]', + REDIS_URL: process.env.REDIS_URL ? '[set]' : '[not set]', + ENCRYPTION_KEY: process.env.ENCRYPTION_KEY ? '[set]' : '[not set]', + GEOIP_DB_PATH: process.env.GEOIP_DB_PATH ?? '[not set]', + }, + }; + }); +}; diff --git a/apps/server/src/routes/images.ts b/apps/server/src/routes/images.ts new file mode 100644 index 0000000..a545527 --- /dev/null +++ b/apps/server/src/routes/images.ts @@ -0,0 +1,120 @@ +/** + * Image proxy routes + * + * Provides a proxy endpoint for fetching images from Plex/Jellyfin servers. + * This solves CORS issues and allows resizing/caching of images. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { proxyImage, type FallbackType } from '../services/imageProxy.js'; + +const proxyQuerySchema = z.object({ + server: z.uuid({ error: 'Invalid server ID' }), + url: z.string().min(1, 'Image URL is required'), + width: z.coerce.number().int().min(10).max(2000).optional().default(300), + height: z.coerce.number().int().min(10).max(2000).optional().default(450), + fallback: z.enum(['poster', 'avatar', 'art']).optional().default('poster'), +}); + +export const imageRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /images/proxy - Proxy an image from a media server + * + * Note: No authentication required - images are public once you have + * a valid server ID. This allows tags to work without auth headers. + * Server ID is validated in proxyImage service. + * + * Query params: + * - server: UUID of the server to fetch from + * - url: The image path (e.g., /library/metadata/123/thumb/456) + * - width: Resize width (default 300) + * - height: Resize height (default 450) + * - fallback: Placeholder type if image fails (poster, avatar, art) + */ + app.get( + '/proxy', + async (request, reply) => { + const parseResult = proxyQuerySchema.safeParse(request.query); + + if (!parseResult.success) { + return reply.status(400).send({ + error: 'Invalid query parameters', + details: z.treeifyError(parseResult.error), + }); + } + + const { server, url, width, height, fallback } = parseResult.data; + + const result = await proxyImage({ + serverId: server, + imagePath: url, + width, + height, + fallback: fallback as FallbackType, + }); + + // Set cache headers + if (result.cached) { + reply.header('X-Cache', 'HIT'); + } else { + reply.header('X-Cache', 'MISS'); + } + + // Cache for 1 hour in browser, allow CDN caching + reply.header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400'); + reply.header('Content-Type', result.contentType); + + return reply.send(result.data); + } + ); + + /** + * GET /images/avatar - Get a user avatar (with gravatar fallback) + * + * Note: No authentication required for same reason as /proxy + * + * Query params: + * - server: UUID of the server (optional if using gravatar) + * - url: The avatar path from server (optional) + * - email: Email for gravatar fallback (optional) + * - size: Avatar size (default 100) + */ + app.get( + '/avatar', + async (request, reply) => { + const query = request.query as Record; + const server = query.server; + const url = query.url; + const size = parseInt(query.size ?? '100', 10); + + // If we have server URL, try to fetch from media server + if (server && url) { + const result = await proxyImage({ + serverId: server, + imagePath: url, + width: size, + height: size, + fallback: 'avatar', + }); + + reply.header('Cache-Control', 'public, max-age=3600'); + reply.header('Content-Type', result.contentType); + return reply.send(result.data); + } + + // Return fallback avatar + const result = await proxyImage({ + serverId: 'fallback', + imagePath: 'fallback', + width: size, + height: size, + fallback: 'avatar', + }); + + reply.header('Cache-Control', 'public, max-age=86400'); + reply.header('Content-Type', result.contentType); + return reply.send(result.data); + } + ); +}; diff --git a/apps/server/src/routes/import.ts b/apps/server/src/routes/import.ts new file mode 100644 index 0000000..a945e1b --- /dev/null +++ b/apps/server/src/routes/import.ts @@ -0,0 +1,214 @@ +/** + * Import routes - Data import from external sources + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { tautulliImportSchema } from '@tracearr/shared'; +import { TautulliService } from '../services/tautulli.js'; +import { getPubSubService } from '../services/cache.js'; +import { syncServer } from '../services/sync.js'; +import { + enqueueImport, + getImportStatus, + cancelImport, + getImportQueueStats, + getActiveImportForServer, +} from '../jobs/importQueue.js'; + +export const importRoutes: FastifyPluginAsync = async (app) => { + /** + * POST /import/tautulli - Start Tautulli import (enqueues job) + */ + app.post( + '/tautulli', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = tautulliImportSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body: serverId is required'); + } + + const authUser = request.user; + + // Only owners can import data + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can import data'); + } + + const { serverId } = body.data; + + // Sync server users first to ensure we have all users before importing history + try { + app.log.info({ serverId }, 'Syncing server before Tautulli import'); + await syncServer(serverId, { syncUsers: true, syncLibraries: false }); + app.log.info({ serverId }, 'Server sync completed, enqueueing import'); + } catch (error) { + app.log.error({ error, serverId }, 'Failed to sync server before import'); + return reply.internalServerError('Failed to sync server users before import'); + } + + // Enqueue import job + try { + const jobId = await enqueueImport(serverId, authUser.userId); + + return { + status: 'queued', + jobId, + message: + 'Import queued. Use jobId to track progress via WebSocket or GET /import/tautulli/:jobId', + }; + } catch (error) { + if (error instanceof Error && error.message.includes('already in progress')) { + return reply.conflict(error.message); + } + + // Fallback to direct execution if queue is not available + app.log.warn({ error }, 'Import queue unavailable, falling back to direct execution'); + + const pubSubService = getPubSubService(); + + // Start import in background (non-blocking) + TautulliService.importHistory(serverId, pubSubService ?? undefined) + .then((result) => { + console.log(`[Import] Tautulli import completed:`, result); + }) + .catch((err: unknown) => { + console.error(`[Import] Tautulli import failed:`, err); + }); + + return { + status: 'started', + message: 'Import started (direct execution). Watch for progress updates via WebSocket.', + }; + } + } + ); + + /** + * GET /import/tautulli/active/:serverId - Get active import for a server (if any) + * Use this to recover import status after page refresh + */ + app.get<{ Params: { serverId: string } }>( + '/tautulli/active/:serverId', + { preHandler: [app.authenticate] }, + async (request, _reply) => { + const { serverId } = request.params; + + const jobId = await getActiveImportForServer(serverId); + if (!jobId) { + return { active: false }; + } + + const status = await getImportStatus(jobId); + if (!status) { + return { active: false }; + } + + return { active: true, ...status }; + } + ); + + /** + * GET /import/tautulli/:jobId - Get import job status + */ + app.get<{ Params: { jobId: string } }>( + '/tautulli/:jobId', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const { jobId } = request.params; + + const status = await getImportStatus(jobId); + if (!status) { + return reply.notFound('Import job not found'); + } + + return status; + } + ); + + /** + * DELETE /import/tautulli/:jobId - Cancel import job + */ + app.delete<{ Params: { jobId: string } }>( + '/tautulli/:jobId', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const authUser = request.user; + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can cancel imports'); + } + + const { jobId } = request.params; + const cancelled = await cancelImport(jobId); + + if (!cancelled) { + return reply.badRequest('Cannot cancel job (may be active or not found)'); + } + + return { status: 'cancelled', jobId }; + } + ); + + /** + * GET /import/stats - Get import queue statistics + */ + app.get('/stats', { preHandler: [app.authenticate] }, async (_request, reply) => { + const stats = await getImportQueueStats(); + + if (!stats) { + return reply.serviceUnavailable('Import queue not available'); + } + + return stats; + }); + + /** + * POST /import/tautulli/test - Test Tautulli connection + */ + app.post( + '/tautulli/test', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const authUser = request.user; + + // Only owners can test connection + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can test Tautulli connection'); + } + + const body = request.body as { url?: string; apiKey?: string } | undefined; + + if (!body?.url || !body?.apiKey) { + return reply.badRequest('URL and API key are required'); + } + + try { + const tautulli = new TautulliService(body.url, body.apiKey); + const connected = await tautulli.testConnection(); + + if (connected) { + // Get user count to verify full access + const users = await tautulli.getUsers(); + const { total } = await tautulli.getHistory(0, 1); + + return { + success: true, + message: 'Connection successful', + users: users.length, + historyRecords: total, + }; + } else { + return { + success: false, + message: 'Connection failed. Please check URL and API key.', + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Connection failed', + }; + } + } + ); +}; diff --git a/apps/server/src/routes/mobile.ts b/apps/server/src/routes/mobile.ts new file mode 100644 index 0000000..098a516 --- /dev/null +++ b/apps/server/src/routes/mobile.ts @@ -0,0 +1,919 @@ +/** + * Mobile routes - Mobile app pairing, authentication, and session management + * + * Settings endpoints (owner only): + * - GET /mobile - Get mobile config (enabled status, sessions) + * - POST /mobile/enable - Enable mobile access + * - POST /mobile/disable - Disable mobile access + * - POST /mobile/pair-token - Generate one-time pairing token + * - DELETE /mobile/sessions - Revoke all mobile sessions + * - DELETE /mobile/sessions/:id - Revoke single mobile session + * + * Auth endpoints (mobile app): + * - POST /mobile/pair - Exchange pairing token for JWT + * - POST /mobile/refresh - Refresh mobile JWT + * - POST /mobile/push-token - Register push token + * + * Stream management (admin/owner via mobile): + * - POST /mobile/streams/:id/terminate - Terminate a playback session + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { createHash, randomBytes } from 'crypto'; +import { eq, and, gt, isNull, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import type { MobileConfig, MobileSession, MobilePairResponse, MobilePairTokenResponse } from '@tracearr/shared'; +import { REDIS_KEYS, CACHE_TTL, sessionIdParamSchema, terminateSessionBodySchema } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { mobileTokens, mobileSessions, servers, users, settings, sessions } from '../db/schema.js'; +import { terminateSession } from '../services/termination.js'; +import { hasServerAccess } from '../utils/serverFiltering.js'; + +// Rate limits for mobile auth endpoints +const MOBILE_PAIR_MAX_ATTEMPTS = 5; // 5 attempts per 15 minutes +const MOBILE_REFRESH_MAX_ATTEMPTS = 30; // 30 attempts per 15 minutes + +// Beta mode: allows reusable tokens, no expiry, unlimited devices +// Useful for TestFlight/beta testing where you need to share a single token +// Using a function to allow dynamic checking (useful for testing) +function isBetaMode(): boolean { + return process.env.MOBILE_BETA_MODE === 'true'; +} + +// Limits +const MAX_PAIRED_DEVICES = 5; +const MAX_PENDING_TOKENS = 3; +const TOKEN_EXPIRY_MINUTES = 15; +const BETA_TOKEN_EXPIRY_YEARS = 100; // Effectively never expires +const TOKEN_GEN_RATE_LIMIT = 3; // Max tokens per 5 minutes +const TOKEN_GEN_RATE_WINDOW = 5 * 60; // 5 minutes in seconds + +// Token format: trr_mob_<32 random bytes as base64url> +const MOBILE_TOKEN_PREFIX = 'trr_mob_'; + +// Redis key prefixes for mobile refresh tokens +const MOBILE_REFRESH_PREFIX = 'tracearr:mobile_refresh:'; +const MOBILE_REFRESH_TTL = 90 * 24 * 60 * 60; // 90 days + +// Mobile JWT expiry (longer than web) +const MOBILE_ACCESS_EXPIRY = '7d'; + +// Schemas +const mobilePairSchema = z.object({ + token: z.string().min(1), + deviceName: z.string().min(1).max(100), + deviceId: z.string().min(1).max(100), + platform: z.enum(['ios', 'android']), + deviceSecret: z.string().min(32).max(64).optional(), // Base64-encoded device secret for push encryption +}); + +const mobileRefreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +const pushTokenSchema = z.object({ + expoPushToken: z.string().min(1).regex(/^ExponentPushToken\[.+\]$/, 'Invalid Expo push token format'), + deviceSecret: z.string().min(32).max(64).optional(), // Update device secret for push encryption +}); + +/** + * Generate a new mobile access token + */ +function generateMobileToken(): string { + const randomPart = randomBytes(32).toString('base64url'); + return `${MOBILE_TOKEN_PREFIX}${randomPart}`; +} + +/** + * Hash a token using SHA-256 + */ +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Generate a refresh token + */ +function generateRefreshToken(): string { + return randomBytes(32).toString('hex'); +} + +export const mobileRoutes: FastifyPluginAsync = async (app) => { + // Log beta mode status on startup + if (isBetaMode()) { + app.log.warn('MOBILE_BETA_MODE enabled: tokens are reusable, never expire, unlimited devices allowed'); + } + + // ============================================ + // Settings endpoints (owner only) + // ============================================ + + /** + * GET /mobile - Get mobile config + */ + app.get('/', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can access mobile settings'); + } + + // Get mobile enabled status from settings + const settingsRow = await db.select({ mobileEnabled: settings.mobileEnabled }).from(settings).limit(1); + const isEnabled = settingsRow[0]?.mobileEnabled ?? false; + + // Get mobile sessions + const sessionsRows = await db.select().from(mobileSessions); + + // Count pending tokens (unexpired and unused) + const pendingTokensResult = await db + .select({ count: sql`count(*)::int` }) + .from(mobileTokens) + .where( + and( + gt(mobileTokens.expiresAt, new Date()), + isNull(mobileTokens.usedAt) + ) + ); + const pendingTokens = pendingTokensResult[0]?.count ?? 0; + + // Get server name + const serverRow = await db.select({ name: servers.name }).from(servers).limit(1); + const serverName = serverRow[0]?.name || 'Tracearr'; + + const sessions: MobileSession[] = sessionsRows.map((s) => ({ + id: s.id, + deviceName: s.deviceName, + deviceId: s.deviceId, + platform: s.platform, + expoPushToken: s.expoPushToken, + lastSeenAt: s.lastSeenAt, + createdAt: s.createdAt, + })); + + const config: MobileConfig = { + isEnabled, + sessions, + serverName, + pendingTokens, + maxDevices: MAX_PAIRED_DEVICES, + }; + + return config; + }); + + /** + * POST /mobile/enable - Enable mobile access (no token generated) + */ + app.post('/enable', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can enable mobile access'); + } + + // Update settings to enable mobile + await db + .update(settings) + .set({ mobileEnabled: true, updatedAt: new Date() }) + .where(eq(settings.id, 1)); + + // Get current state for response + const sessionsRows = await db.select().from(mobileSessions); + const serverRow = await db.select({ name: servers.name }).from(servers).limit(1); + const serverName = serverRow[0]?.name || 'Tracearr'; + + const sessions: MobileSession[] = sessionsRows.map((s) => ({ + id: s.id, + deviceName: s.deviceName, + deviceId: s.deviceId, + platform: s.platform, + expoPushToken: s.expoPushToken, + lastSeenAt: s.lastSeenAt, + createdAt: s.createdAt, + })); + + const config: MobileConfig = { + isEnabled: true, + sessions, + serverName, + pendingTokens: 0, + maxDevices: MAX_PAIRED_DEVICES, + }; + + app.log.info({ userId: authUser.userId }, 'Mobile access enabled'); + + return config; + }); + + /** + * POST /mobile/pair-token - Generate a one-time pairing token + * + * Rate limited: 3 tokens per 5 minutes per user + * Max pending tokens: 3 + * Max paired devices: 5 + */ + app.post('/pair-token', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can generate pairing tokens'); + } + + // Check if mobile is enabled + const settingsRow = await db.select({ mobileEnabled: settings.mobileEnabled }).from(settings).limit(1); + if (!settingsRow[0]?.mobileEnabled) { + return reply.badRequest('Mobile access is not enabled'); + } + + // Rate limiting: max 3 tokens per 5 minutes + // Use Lua script for atomic INCR + EXPIRE operation + const rateLimitKey = `mobile_token_gen:${authUser.userId}`; + const luaScript = ` + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + `; + const currentCount = await app.redis.eval(luaScript, 1, rateLimitKey, TOKEN_GEN_RATE_WINDOW) as number; + + if (currentCount > TOKEN_GEN_RATE_LIMIT) { + const ttl = await app.redis.ttl(rateLimitKey); + reply.header('Retry-After', String(ttl > 0 ? ttl : TOKEN_GEN_RATE_WINDOW)); + return reply.tooManyRequests('Too many token generation attempts. Please try again later.'); + } + + // Use transaction to prevent race conditions on device and token limit checks + let plainToken: string; + let expiresAt: Date; + + try { + const result = await db.transaction(async (tx) => { + // Set serializable isolation level to prevent phantom reads + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`); + + // Check max pending tokens (within transaction for consistency) + const pendingTokensResult = await tx + .select({ count: sql`count(*)::int` }) + .from(mobileTokens) + .where( + and( + gt(mobileTokens.expiresAt, new Date()), + isNull(mobileTokens.usedAt) + ) + ); + const pendingCount = pendingTokensResult[0]?.count ?? 0; + + if (pendingCount >= MAX_PENDING_TOKENS) { + throw new Error('MAX_PENDING_TOKENS'); + } + + // Check max paired devices (within transaction to prevent race condition) + const sessionsCount = await tx + .select({ count: sql`count(*)::int` }) + .from(mobileSessions); + const deviceCount = sessionsCount[0]?.count ?? 0; + + // In beta mode, allow unlimited devices + if (!isBetaMode() && deviceCount >= MAX_PAIRED_DEVICES) { + throw new Error('MAX_PAIRED_DEVICES'); + } + + // Generate token + const token = generateMobileToken(); + const tokenHash = hashToken(token); + // In beta mode, tokens effectively never expire + const expiryMs = isBetaMode() + ? BETA_TOKEN_EXPIRY_YEARS * 365 * 24 * 60 * 60 * 1000 + : TOKEN_EXPIRY_MINUTES * 60 * 1000; + const expires = new Date(Date.now() + expiryMs); + + await tx.insert(mobileTokens).values({ + tokenHash, + expiresAt: expires, + createdBy: authUser.userId, + }); + + return { token, expires }; + }); + + plainToken = result.token; + expiresAt = result.expires; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + + if (message === 'MAX_PENDING_TOKENS') { + return reply.badRequest( + `Maximum of ${MAX_PENDING_TOKENS} pending tokens allowed. Wait for expiry or use an existing token.` + ); + } + if (message === 'MAX_PAIRED_DEVICES') { + return reply.badRequest( + `Maximum of ${MAX_PAIRED_DEVICES} devices allowed. Remove a device first.` + ); + } + + app.log.error({ err }, 'Token generation transaction failed'); + return reply.internalServerError('Failed to generate token. Please try again.'); + } + + app.log.info({ userId: authUser.userId }, 'Mobile pairing token generated'); + + const response: MobilePairTokenResponse = { + token: plainToken, + expiresAt, + }; + + return response; + }); + + /** + * POST /mobile/disable - Disable mobile access + */ + app.post('/disable', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can disable mobile access'); + } + + // Disable in settings + await db + .update(settings) + .set({ mobileEnabled: false, updatedAt: new Date() }) + .where(eq(settings.id, 1)); + + // Revoke all mobile sessions (delete from DB and Redis) + const sessionsRows = await db.select().from(mobileSessions); + for (const session of sessionsRows) { + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${session.refreshTokenHash}`); + } + await db.delete(mobileSessions); + + // Delete all pending tokens + await db.delete(mobileTokens); + + app.log.info({ userId: authUser.userId }, 'Mobile access disabled'); + + return { success: true }; + }); + + /** + * DELETE /mobile/sessions - Revoke all mobile sessions + */ + app.delete('/sessions', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can revoke mobile sessions'); + } + + // Delete all sessions from Redis and DB + const sessionsRows = await db.select().from(mobileSessions); + for (const session of sessionsRows) { + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${session.refreshTokenHash}`); + } + await db.delete(mobileSessions); + + app.log.info({ userId: authUser.userId, count: sessionsRows.length }, 'All mobile sessions revoked'); + + return { success: true, revokedCount: sessionsRows.length }; + }); + + /** + * DELETE /mobile/sessions/:id - Revoke a single mobile session + */ + app.delete('/sessions/:id', { preHandler: [app.authenticate] }, async (request, reply) => { + const authUser = request.user; + + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can revoke mobile sessions'); + } + + const { id } = request.params as { id: string }; + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return reply.badRequest('Invalid session ID format'); + } + + // Find the session + const sessionRow = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.id, id)) + .limit(1); + + if (sessionRow.length === 0) { + return reply.notFound('Mobile session not found'); + } + + const session = sessionRow[0]!; + + // Delete refresh token from Redis + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${session.refreshTokenHash}`); + + // Delete session from DB (notification_preferences cascade-deleted via FK) + await db.delete(mobileSessions).where(eq(mobileSessions.id, id)); + + app.log.info( + { userId: authUser.userId, sessionId: id, deviceName: session.deviceName }, + 'Mobile session revoked' + ); + + return { success: true }; + }); + + // ============================================ + // Auth endpoints (mobile app) + // ============================================ + + /** + * POST /mobile/pair - Exchange pairing token for JWT + * + * Rate limited: 5 attempts per IP per 15 minutes to prevent brute force + */ + app.post('/pair', async (request, reply) => { + // Rate limiting check - use Lua script for atomic INCR + EXPIRE + const clientIp = request.ip; + const rateLimitKey = REDIS_KEYS.RATE_LIMIT_MOBILE_PAIR(clientIp); + const luaScript = ` + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + `; + const currentCount = await app.redis.eval(luaScript, 1, rateLimitKey, CACHE_TTL.RATE_LIMIT) as number; + + if (currentCount > MOBILE_PAIR_MAX_ATTEMPTS) { + const ttl = await app.redis.ttl(rateLimitKey); + app.log.warn({ ip: clientIp, count: currentCount }, 'Mobile pair rate limit exceeded'); + reply.header('Retry-After', String(ttl > 0 ? ttl : CACHE_TTL.RATE_LIMIT)); + return reply.tooManyRequests('Too many pairing attempts. Please try again later.'); + } + + const body = mobilePairSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid pairing request'); + } + + const { token, deviceName, deviceId, platform, deviceSecret } = body.data; + + // Verify token starts with correct prefix + if (!token.startsWith(MOBILE_TOKEN_PREFIX)) { + return reply.unauthorized('Invalid mobile token'); + } + + const tokenHash = hashToken(token); + + // Check max devices before attempting pair + const sessionsCount = await db + .select({ count: sql`count(*)::int` }) + .from(mobileSessions); + const deviceCount = sessionsCount[0]?.count ?? 0; + + // Check if this device is already paired (would be an update, not new) + const existingSession = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, deviceId)) + .limit(1); + + // In beta mode, allow unlimited devices + if (!isBetaMode() && existingSession.length === 0 && deviceCount >= MAX_PAIRED_DEVICES) { + return reply.badRequest( + `Maximum of ${MAX_PAIRED_DEVICES} devices allowed. Remove a device first.` + ); + } + + // Use transaction with row-level locking to prevent race conditions + let result: { + accessToken: string; + refreshToken: string; + owner: { id: string; username: string }; + serverName: string; + serverId: string; + serverType: 'plex' | 'jellyfin' | 'emby'; + serverIds: string[]; + oldRefreshTokenHash?: string; // Track old hash for cleanup outside transaction + }; + + try { + result = await db.transaction(async (tx) => { + // Set serializable isolation level to prevent phantom reads + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`); + + // Lock and validate token + const tokenRows = await tx + .select() + .from(mobileTokens) + .where(eq(mobileTokens.tokenHash, tokenHash)) + .for('update') + .limit(1); + + if (tokenRows.length === 0) { + throw new Error('INVALID_TOKEN'); + } + + const tokenRow = tokenRows[0]!; + + // In beta mode, allow tokens to be reused + if (tokenRow.usedAt && !isBetaMode()) { + throw new Error('TOKEN_ALREADY_USED'); + } + + if (tokenRow.expiresAt < new Date()) { + throw new Error('TOKEN_EXPIRED'); + } + + // Get the owner user + const ownerRow = await tx + .select() + .from(users) + .where(eq(users.role, 'owner')) + .limit(1); + + if (ownerRow.length === 0) { + throw new Error('NO_OWNER'); + } + + const owner = ownerRow[0]!; + + // Get all server IDs for the JWT + const allServers = await tx.select({ id: servers.id, name: servers.name, type: servers.type }).from(servers); + const serverIds = allServers.map((s) => s.id); + + // Get primary server info for the response (first server) + const primaryServer = allServers[0]; + const serverName = primaryServer?.name || 'Tracearr'; + const serverId = primaryServer?.id || ''; + const serverType = primaryServer?.type || 'plex'; + + // Generate refresh token + const newRefreshToken = generateRefreshToken(); + const refreshTokenHash = hashToken(newRefreshToken); + + // Track old refresh token hash for cleanup (if updating existing session) + let oldHash: string | undefined; + + // Create or update session + if (existingSession.length > 0) { + // Update existing session - save old hash for cleanup outside transaction + oldHash = existingSession[0]!.refreshTokenHash; + + await tx + .update(mobileSessions) + .set({ + refreshTokenHash, + deviceName, + platform, + deviceSecret: deviceSecret ?? null, + lastSeenAt: new Date(), + // Update userId in case the token creator changed + userId: owner.id, + }) + .where(eq(mobileSessions.id, existingSession[0]!.id)); + } else { + // Create new session - link to the owner user who generated the pairing token + await tx.insert(mobileSessions).values({ + refreshTokenHash, + deviceName, + deviceId, + platform, + deviceSecret: deviceSecret ?? null, + userId: owner.id, + }); + } + + // Mark token as used (not deleted - for audit trail) + // In beta mode, don't mark as used so token can be reused + if (!isBetaMode()) { + await tx + .update(mobileTokens) + .set({ usedAt: new Date() }) + .where(eq(mobileTokens.id, tokenRow.id)); + } + + // Generate access token + const accessToken = app.jwt.sign( + { + userId: owner.id, + username: owner.username, + role: 'owner', + serverIds, + mobile: true, + deviceId, + }, + { expiresIn: MOBILE_ACCESS_EXPIRY } + ); + + return { + accessToken, + refreshToken: newRefreshToken, + owner: { id: owner.id, username: owner.username }, + serverName, + serverId, + serverType, + serverIds, + oldRefreshTokenHash: oldHash, + }; + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + + if (message === 'INVALID_TOKEN') { + return reply.unauthorized('Invalid mobile token'); + } + if (message === 'TOKEN_ALREADY_USED') { + return reply.unauthorized('This pairing token has already been used'); + } + if (message === 'TOKEN_EXPIRED') { + return reply.unauthorized('This pairing token has expired'); + } + if (message === 'NO_OWNER') { + return reply.internalServerError('No owner account found'); + } + + app.log.error({ err }, 'Mobile pairing transaction failed'); + return reply.internalServerError('Pairing failed. Please try again.'); + } + + // Redis operations AFTER transaction commits (to prevent inconsistency on rollback) + // Delete old refresh token from Redis if we updated an existing session + if (result.oldRefreshTokenHash) { + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${result.oldRefreshTokenHash}`); + } + + // Store new refresh token in Redis + await app.redis.setex( + `${MOBILE_REFRESH_PREFIX}${hashToken(result.refreshToken)}`, + MOBILE_REFRESH_TTL, + JSON.stringify({ userId: result.owner.id, deviceId }) + ); + + app.log.info({ deviceName, platform, deviceId }, 'Mobile device paired'); + + const response: MobilePairResponse = { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + server: { + id: result.serverId, + name: result.serverName, + type: result.serverType, + }, + user: { + userId: result.owner.id, + username: result.owner.username, + role: 'owner', + }, + }; + + return response; + }); + + /** + * POST /mobile/refresh - Refresh mobile JWT + * + * Rate limited: 30 attempts per IP per 15 minutes to prevent abuse + */ + app.post('/refresh', async (request, reply) => { + // Rate limiting check - use Lua script for atomic INCR + EXPIRE + const clientIp = request.ip; + const rateLimitKey = REDIS_KEYS.RATE_LIMIT_MOBILE_REFRESH(clientIp); + const luaScript = ` + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + `; + const currentCount = await app.redis.eval(luaScript, 1, rateLimitKey, CACHE_TTL.RATE_LIMIT) as number; + + if (currentCount > MOBILE_REFRESH_MAX_ATTEMPTS) { + const ttl = await app.redis.ttl(rateLimitKey); + app.log.warn({ ip: clientIp, count: currentCount }, 'Mobile refresh rate limit exceeded'); + reply.header('Retry-After', String(ttl > 0 ? ttl : CACHE_TTL.RATE_LIMIT)); + return reply.tooManyRequests('Too many refresh attempts. Please try again later.'); + } + + const body = mobileRefreshSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid refresh request'); + } + + const { refreshToken } = body.data; + const refreshTokenHash = hashToken(refreshToken); + + // Check Redis for valid refresh token + const stored = await app.redis.get(`${MOBILE_REFRESH_PREFIX}${refreshTokenHash}`); + if (!stored) { + return reply.unauthorized('Invalid or expired refresh token'); + } + + const { userId, deviceId } = JSON.parse(stored) as { userId: string; deviceId: string }; + + // Verify user still exists and is owner + const userRow = await db.select().from(users).where(eq(users.id, userId)).limit(1); + if (userRow.length === 0 || userRow[0]!.role !== 'owner') { + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${refreshTokenHash}`); + return reply.unauthorized('User no longer valid'); + } + + const user = userRow[0]!; + + // Verify mobile session still exists + const sessionRow = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.refreshTokenHash, refreshTokenHash)) + .limit(1); + + if (sessionRow.length === 0) { + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${refreshTokenHash}`); + return reply.unauthorized('Session has been revoked'); + } + + // Get all server IDs + const allServers = await db.select({ id: servers.id }).from(servers); + const serverIds = allServers.map((s) => s.id); + + // Generate new access token + const accessToken = app.jwt.sign( + { + userId: user.id, + username: user.username, + role: 'owner', + serverIds, + mobile: true, + deviceId, // Device identifier for session targeting + }, + { expiresIn: MOBILE_ACCESS_EXPIRY } + ); + + // Rotate refresh token + const newRefreshToken = generateRefreshToken(); + const newRefreshTokenHash = hashToken(newRefreshToken); + + // Update session with new refresh token + await db + .update(mobileSessions) + .set({ + refreshTokenHash: newRefreshTokenHash, + lastSeenAt: new Date(), + }) + .where(eq(mobileSessions.id, sessionRow[0]!.id)); + + // Update Redis + await app.redis.del(`${MOBILE_REFRESH_PREFIX}${refreshTokenHash}`); + await app.redis.setex( + `${MOBILE_REFRESH_PREFIX}${newRefreshTokenHash}`, + MOBILE_REFRESH_TTL, + JSON.stringify({ userId, deviceId }) + ); + + return { + accessToken, + refreshToken: newRefreshToken, + }; + }); + + /** + * POST /mobile/push-token - Register/update Expo push token for notifications + */ + app.post('/push-token', { preHandler: [app.requireMobile] }, async (request, reply) => { + const body = pushTokenSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid push token format. Expected ExponentPushToken[...]'); + } + + const { expoPushToken, deviceSecret } = body.data; + const authUser = request.user; + + // Ensure we have deviceId from JWT (required for mobile tokens) + if (!authUser.deviceId) { + return reply.badRequest('Invalid mobile token: missing deviceId. Please re-pair the device.'); + } + + // Build update object (only include deviceSecret if provided) + const updateData: { expoPushToken: string; lastSeenAt: Date; deviceSecret?: string } = { + expoPushToken, + lastSeenAt: new Date(), + }; + if (deviceSecret) { + updateData.deviceSecret = deviceSecret; + } + + // Update only the specific device session identified by deviceId + const updated = await db + .update(mobileSessions) + .set(updateData) + .where(eq(mobileSessions.deviceId, authUser.deviceId)) + .returning({ id: mobileSessions.id }); + + if (updated.length === 0) { + return reply.notFound('No mobile session found for this device. Please pair the device first.'); + } + + app.log.info( + { userId: authUser.userId, deviceId: authUser.deviceId }, + 'Push token registered for mobile session' + ); + + return { success: true, updatedSessions: updated.length }; + }); + + // ============================================================================ + // Stream Management Endpoints (admin/owner via mobile) + // ============================================================================ + + /** + * POST /mobile/streams/:id/terminate - Terminate a playback session + * + * Requires mobile authentication with admin/owner role. + * Sends a stop command to the media server and logs the termination. + */ + app.post( + '/streams/:id/terminate', + { preHandler: [app.requireMobile] }, + async (request, reply) => { + const params = sessionIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid session ID'); + } + + const body = terminateSessionBodySchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { id } = params.data; + const { reason } = body.data; + const authUser = request.user; + + // Only admins and owners can terminate sessions + if (authUser.role !== 'owner' && authUser.role !== 'admin') { + return reply.forbidden('Only administrators can terminate sessions'); + } + + // Verify the session exists and user has access to its server + const session = await db + .select({ + id: sessions.id, + serverId: sessions.serverId, + serverUserId: sessions.serverUserId, + state: sessions.state, + }) + .from(sessions) + .where(eq(sessions.id, id)) + .limit(1); + + const sessionData = session[0]; + if (!sessionData) { + return reply.notFound('Session not found'); + } + + if (!hasServerAccess(authUser, sessionData.serverId)) { + return reply.forbidden('You do not have access to this server'); + } + + // Check if session is already stopped + if (sessionData.state === 'stopped') { + return reply.conflict('Session has already ended'); + } + + // Attempt termination + const result = await terminateSession({ + sessionId: id, + trigger: 'manual', + triggeredByUserId: authUser.userId, + reason, + }); + + if (!result.success) { + app.log.error( + { sessionId: id, error: result.error, terminationLogId: result.terminationLogId }, + 'Failed to terminate session from mobile' + ); + return reply.code(500).send({ + success: false, + error: result.error, + terminationLogId: result.terminationLogId, + }); + } + + app.log.info( + { sessionId: id, userId: authUser.userId, deviceId: authUser.deviceId }, + 'Session terminated from mobile app' + ); + + return { + success: true, + terminationLogId: result.terminationLogId, + message: 'Stream termination command sent successfully', + }; + } + ); +}; diff --git a/apps/server/src/routes/notificationPreferences.ts b/apps/server/src/routes/notificationPreferences.ts new file mode 100644 index 0000000..da61550 --- /dev/null +++ b/apps/server/src/routes/notificationPreferences.ts @@ -0,0 +1,328 @@ +/** + * Notification Preferences routes - Per-device notification configuration + * + * Mobile device endpoints: + * - GET /notifications/preferences - Get preferences for current device + * - PATCH /notifications/preferences - Update preferences for current device + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc } from 'drizzle-orm'; +import { z } from 'zod'; +import type { NotificationPreferences, NotificationPreferencesWithStatus } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { mobileSessions, notificationPreferences } from '../db/schema.js'; +import { getPushRateLimiter } from '../services/pushRateLimiter.js'; + +// Update preferences schema +const updatePreferencesSchema = z.object({ + pushEnabled: z.boolean().optional(), + onViolationDetected: z.boolean().optional(), + onStreamStarted: z.boolean().optional(), + onStreamStopped: z.boolean().optional(), + onConcurrentStreams: z.boolean().optional(), + onNewDevice: z.boolean().optional(), + onTrustScoreChanged: z.boolean().optional(), + onServerDown: z.boolean().optional(), + onServerUp: z.boolean().optional(), + violationMinSeverity: z.number().int().min(1).max(3).optional(), + violationRuleTypes: z.array(z.string()).optional(), + maxPerMinute: z.number().int().min(1).max(60).optional(), + maxPerHour: z.number().int().min(1).max(1000).optional(), + quietHoursEnabled: z.boolean().optional(), + quietHoursStart: z.string().regex(/^\d{2}:\d{2}$/).optional().nullable(), + quietHoursEnd: z.string().regex(/^\d{2}:\d{2}$/).optional().nullable(), + quietHoursTimezone: z.string().max(50).optional(), + quietHoursOverrideCritical: z.boolean().optional(), +}); + +/** + * Find mobile session by deviceId from JWT claims + * Mobile JWTs include deviceId for targeting the correct device session + */ +async function findMobileSessionByDeviceId(deviceId: string): Promise<{ id: string } | null> { + const session = await db + .select({ id: mobileSessions.id }) + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, deviceId)) + .limit(1); + + return session[0] ?? null; +} + +/** + * Find mobile session for user (fallback for legacy tokens without deviceId) + * Gets the most recently active session for the owner + */ +async function findMobileSessionForUserFallback(_userId: string): Promise<{ id: string } | null> { + // Get the most recently active session (desc ordering) + const session = await db + .select({ id: mobileSessions.id }) + .from(mobileSessions) + .orderBy(desc(mobileSessions.lastSeenAt)) + .limit(1); + + return session[0] ?? null; +} + +/** + * Transform DB row to API response + */ +function toApiResponse(row: typeof notificationPreferences.$inferSelect): NotificationPreferences { + return { + id: row.id, + mobileSessionId: row.mobileSessionId, + pushEnabled: row.pushEnabled, + onViolationDetected: row.onViolationDetected, + onStreamStarted: row.onStreamStarted, + onStreamStopped: row.onStreamStopped, + onConcurrentStreams: row.onConcurrentStreams, + onNewDevice: row.onNewDevice, + onTrustScoreChanged: row.onTrustScoreChanged, + onServerDown: row.onServerDown, + onServerUp: row.onServerUp, + violationMinSeverity: row.violationMinSeverity, + violationRuleTypes: row.violationRuleTypes ?? [], + maxPerMinute: row.maxPerMinute, + maxPerHour: row.maxPerHour, + quietHoursEnabled: row.quietHoursEnabled, + quietHoursStart: row.quietHoursStart, + quietHoursEnd: row.quietHoursEnd, + quietHoursTimezone: row.quietHoursTimezone ?? 'UTC', + quietHoursOverrideCritical: row.quietHoursOverrideCritical, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export const notificationPreferencesRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /notifications/preferences - Get preferences for current device + * + * Requires mobile authentication. Returns preferences for the device's session, + * or creates default preferences if none exist. + */ + app.get('/preferences', { preHandler: [app.requireMobile] }, async (request, reply) => { + const authUser = request.user; + + // Find mobile session using deviceId (preferred) or fallback to user lookup + const mobileSession = authUser.deviceId + ? await findMobileSessionByDeviceId(authUser.deviceId) + : await findMobileSessionForUserFallback(authUser.userId); + if (!mobileSession) { + return reply.notFound('No mobile session found. Please pair the device first.'); + } + + // Get or create preferences + let prefsRow = await db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.mobileSessionId, mobileSession.id)) + .limit(1); + + if (prefsRow.length === 0) { + // Create default preferences + const inserted = await db + .insert(notificationPreferences) + .values({ + mobileSessionId: mobileSession.id, + }) + .returning(); + + prefsRow = inserted; + } + + const row = prefsRow[0]; + if (!row) { + return reply.internalServerError('Failed to load notification preferences'); + } + + // Get live rate limit status from Redis + const prefs = toApiResponse(row); + const rateLimiter = getPushRateLimiter(); + + if (rateLimiter) { + const status = await rateLimiter.getStatus(mobileSession.id, { + maxPerMinute: prefs.maxPerMinute, + maxPerHour: prefs.maxPerHour, + }); + + const response: NotificationPreferencesWithStatus = { + ...prefs, + rateLimitStatus: { + remainingMinute: status.remainingMinute, + remainingHour: status.remainingHour, + resetMinuteIn: status.resetMinuteIn, + resetHourIn: status.resetHourIn, + }, + }; + + return response; + } + + return prefs; + }); + + /** + * PATCH /notifications/preferences - Update preferences for current device + * + * Requires mobile authentication. Updates notification preferences for the + * device's session. + */ + app.patch('/preferences', { preHandler: [app.requireMobile] }, async (request, reply) => { + const body = updatePreferencesSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const authUser = request.user; + + // Find mobile session using deviceId (preferred) or fallback to user lookup + const mobileSession = authUser.deviceId + ? await findMobileSessionByDeviceId(authUser.deviceId) + : await findMobileSessionForUserFallback(authUser.userId); + if (!mobileSession) { + return reply.notFound('No mobile session found. Please pair the device first.'); + } + + // Ensure preferences row exists + let existing = await db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.mobileSessionId, mobileSession.id)) + .limit(1); + + if (existing.length === 0) { + // Create with defaults first + const inserted = await db + .insert(notificationPreferences) + .values({ + mobileSessionId: mobileSession.id, + }) + .returning(); + existing = inserted; + } + + const prefsId = existing[0]!.id; + + // Build update object + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (body.data.pushEnabled !== undefined) { + updateData.pushEnabled = body.data.pushEnabled; + } + if (body.data.onViolationDetected !== undefined) { + updateData.onViolationDetected = body.data.onViolationDetected; + } + if (body.data.onStreamStarted !== undefined) { + updateData.onStreamStarted = body.data.onStreamStarted; + } + if (body.data.onStreamStopped !== undefined) { + updateData.onStreamStopped = body.data.onStreamStopped; + } + if (body.data.onConcurrentStreams !== undefined) { + updateData.onConcurrentStreams = body.data.onConcurrentStreams; + } + if (body.data.onNewDevice !== undefined) { + updateData.onNewDevice = body.data.onNewDevice; + } + if (body.data.onTrustScoreChanged !== undefined) { + updateData.onTrustScoreChanged = body.data.onTrustScoreChanged; + } + if (body.data.onServerDown !== undefined) { + updateData.onServerDown = body.data.onServerDown; + } + if (body.data.onServerUp !== undefined) { + updateData.onServerUp = body.data.onServerUp; + } + if (body.data.violationMinSeverity !== undefined) { + updateData.violationMinSeverity = body.data.violationMinSeverity; + } + if (body.data.violationRuleTypes !== undefined) { + updateData.violationRuleTypes = body.data.violationRuleTypes; + } + if (body.data.maxPerMinute !== undefined) { + updateData.maxPerMinute = body.data.maxPerMinute; + } + if (body.data.maxPerHour !== undefined) { + updateData.maxPerHour = body.data.maxPerHour; + } + if (body.data.quietHoursEnabled !== undefined) { + updateData.quietHoursEnabled = body.data.quietHoursEnabled; + } + if (body.data.quietHoursStart !== undefined) { + updateData.quietHoursStart = body.data.quietHoursStart; + } + if (body.data.quietHoursEnd !== undefined) { + updateData.quietHoursEnd = body.data.quietHoursEnd; + } + if (body.data.quietHoursTimezone !== undefined) { + updateData.quietHoursTimezone = body.data.quietHoursTimezone; + } + if (body.data.quietHoursOverrideCritical !== undefined) { + updateData.quietHoursOverrideCritical = body.data.quietHoursOverrideCritical; + } + + // Update preferences + await db + .update(notificationPreferences) + .set(updateData) + .where(eq(notificationPreferences.id, prefsId)); + + // Return updated preferences + const updated = await db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.id, prefsId)) + .limit(1); + + const row = updated[0]; + if (!row) { + return reply.internalServerError('Failed to update notification preferences'); + } + + app.log.info( + { userId: authUser.userId, mobileSessionId: mobileSession.id }, + 'Notification preferences updated' + ); + + return toApiResponse(row); + }); +}; + +/** + * Get notification preferences for a specific mobile session (internal use) + */ +export async function getPreferencesForSession( + mobileSessionId: string +): Promise { + const prefs = await db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.mobileSessionId, mobileSessionId)) + .limit(1); + + return prefs[0] ?? null; +} + +/** + * Get notification preferences for a push token (internal use by push service) + */ +export async function getPreferencesForPushToken( + expoPushToken: string +): Promise { + // Find the mobile session with this push token + const session = await db + .select({ id: mobileSessions.id }) + .from(mobileSessions) + .where(eq(mobileSessions.expoPushToken, expoPushToken)) + .limit(1); + + if (session.length === 0 || !session[0]) { + return null; + } + + return getPreferencesForSession(session[0].id); +} diff --git a/apps/server/src/routes/rules.ts b/apps/server/src/routes/rules.ts new file mode 100644 index 0000000..f4dc931 --- /dev/null +++ b/apps/server/src/routes/rules.ts @@ -0,0 +1,320 @@ +/** + * Rule management routes - CRUD for sharing detection rules + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, sql } from 'drizzle-orm'; +import { + createRuleSchema, + updateRuleSchema, + ruleIdParamSchema, +} from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { rules, serverUsers, violations, servers } from '../db/schema.js'; +import { hasServerAccess } from '../utils/serverFiltering.js'; + +export const ruleRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /rules - List all rules + * + * Rules can be: + * - Global (serverUserId = null) - applies to all servers, visible to all + * - User-specific (serverUserId set) - only visible if user has access to that server + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request) => { + const authUser = request.user; + + // Get all rules with server user and server information + const ruleList = await db + .select({ + id: rules.id, + name: rules.name, + type: rules.type, + params: rules.params, + serverUserId: rules.serverUserId, + username: serverUsers.username, + serverId: serverUsers.serverId, + serverName: servers.name, + isActive: rules.isActive, + createdAt: rules.createdAt, + updatedAt: rules.updatedAt, + }) + .from(rules) + .leftJoin(serverUsers, eq(rules.serverUserId, serverUsers.id)) + .leftJoin(servers, eq(serverUsers.serverId, servers.id)) + .orderBy(rules.name); + + // Filter rules by server access + // Global rules (serverUserId = null) are visible to all + // User-specific rules require server access + const filteredRules = ruleList.filter((rule) => { + // Global rule - visible to everyone + if (!rule.serverUserId) return true; + // User-specific rule - check server access + if (!rule.serverId) return false; // Shouldn't happen, but defensive + return hasServerAccess(authUser, rule.serverId); + }); + + return { data: filteredRules }; + } + ); + + /** + * POST /rules - Create a new rule + */ + app.post( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = createRuleSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const authUser = request.user; + + // Only owners can create rules + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can create rules'); + } + + const { name, type, params, serverUserId, isActive } = body.data; + + // Verify serverUserId exists and user has access if provided + if (serverUserId) { + const serverUserRows = await db + .select({ + id: serverUsers.id, + serverId: serverUsers.serverId, + }) + .from(serverUsers) + .where(eq(serverUsers.id, serverUserId)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('Server user not found'); + } + + // Verify owner has access to this server + if (!hasServerAccess(authUser, serverUser.serverId)) { + return reply.forbidden('You do not have access to this server'); + } + } + + // Create rule + const inserted = await db + .insert(rules) + .values({ + name, + type, + params, + serverUserId, + isActive, + }) + .returning(); + + const rule = inserted[0]; + if (!rule) { + return reply.internalServerError('Failed to create rule'); + } + + return reply.status(201).send(rule); + } + ); + + /** + * GET /rules/:id - Get a specific rule + */ + app.get( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = ruleIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid rule ID'); + } + + const { id } = params.data; + const authUser = request.user; + + const ruleRows = await db + .select({ + id: rules.id, + name: rules.name, + type: rules.type, + params: rules.params, + serverUserId: rules.serverUserId, + username: serverUsers.username, + serverId: serverUsers.serverId, + serverName: servers.name, + isActive: rules.isActive, + createdAt: rules.createdAt, + updatedAt: rules.updatedAt, + }) + .from(rules) + .leftJoin(serverUsers, eq(rules.serverUserId, serverUsers.id)) + .leftJoin(servers, eq(serverUsers.serverId, servers.id)) + .where(eq(rules.id, id)) + .limit(1); + + const rule = ruleRows[0]; + if (!rule) { + return reply.notFound('Rule not found'); + } + + // Check access for user-specific rules + if (rule.serverUserId && rule.serverId && !hasServerAccess(authUser, rule.serverId)) { + return reply.forbidden('You do not have access to this rule'); + } + + // Get violation count for this rule + const violationCount = await db + .select({ count: sql`count(*)::int` }) + .from(violations) + .where(eq(violations.ruleId, id)); + + return { + ...rule, + violationCount: violationCount[0]?.count ?? 0, + }; + } + ); + + /** + * PATCH /rules/:id - Update a rule + */ + app.patch( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = ruleIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid rule ID'); + } + + const body = updateRuleSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can update rules + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can update rules'); + } + + // Check rule exists and get server info + const ruleRows = await db + .select({ + id: rules.id, + serverUserId: rules.serverUserId, + serverId: serverUsers.serverId, + }) + .from(rules) + .leftJoin(serverUsers, eq(rules.serverUserId, serverUsers.id)) + .where(eq(rules.id, id)) + .limit(1); + + const existingRule = ruleRows[0]; + if (!existingRule) { + return reply.notFound('Rule not found'); + } + + // Check access for user-specific rules + if (existingRule.serverUserId && existingRule.serverId && !hasServerAccess(authUser, existingRule.serverId)) { + return reply.forbidden('You do not have access to this rule'); + } + + // Build update object + const updateData: Partial<{ + name: string; + params: Record; + isActive: boolean; + updatedAt: Date; + }> = { + updatedAt: new Date(), + }; + + if (body.data.name !== undefined) { + updateData.name = body.data.name; + } + + if (body.data.params !== undefined) { + updateData.params = body.data.params; + } + + if (body.data.isActive !== undefined) { + updateData.isActive = body.data.isActive; + } + + // Update rule + const updated = await db + .update(rules) + .set(updateData) + .where(eq(rules.id, id)) + .returning(); + + const updatedRule = updated[0]; + if (!updatedRule) { + return reply.internalServerError('Failed to update rule'); + } + + return updatedRule; + } + ); + + /** + * DELETE /rules/:id - Delete a rule + */ + app.delete( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = ruleIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid rule ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can delete rules + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can delete rules'); + } + + // Check rule exists and get server info + const ruleRows = await db + .select({ + id: rules.id, + serverUserId: rules.serverUserId, + serverId: serverUsers.serverId, + }) + .from(rules) + .leftJoin(serverUsers, eq(rules.serverUserId, serverUsers.id)) + .where(eq(rules.id, id)) + .limit(1); + + const existingRule = ruleRows[0]; + if (!existingRule) { + return reply.notFound('Rule not found'); + } + + // Check access for user-specific rules + if (existingRule.serverUserId && existingRule.serverId && !hasServerAccess(authUser, existingRule.serverId)) { + return reply.forbidden('You do not have access to this rule'); + } + + // Delete rule (cascade will handle violations) + await db.delete(rules).where(eq(rules.id, id)); + + return { success: true }; + } + ); +}; diff --git a/apps/server/src/routes/servers.ts b/apps/server/src/routes/servers.ts new file mode 100644 index 0000000..53ede5b --- /dev/null +++ b/apps/server/src/routes/servers.ts @@ -0,0 +1,375 @@ +/** + * Server management routes - CRUD for Plex/Jellyfin/Emby servers + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, inArray } from 'drizzle-orm'; +import { createServerSchema, serverIdParamSchema, SERVER_STATS_CONFIG } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { servers } from '../db/schema.js'; +// Token encryption removed - tokens now stored in plain text (DB is localhost-only) +import { PlexClient, JellyfinClient, EmbyClient } from '../services/mediaServer/index.js'; +import { syncServer } from '../services/sync.js'; + +export const serverRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /servers - List connected servers + * Returns all servers (without tokens) for the authenticated user + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request) => { + const authUser = request.user; + + // Owners see all servers, guests only see their authorized servers + const serverList = await db + .select({ + id: servers.id, + name: servers.name, + type: servers.type, + url: servers.url, + createdAt: servers.createdAt, + updatedAt: servers.updatedAt, + }) + .from(servers) + .where( + authUser.role === 'owner' + ? undefined // Owners see all servers + : authUser.serverIds.length > 0 + ? inArray(servers.id, authUser.serverIds) + : undefined // No serverIds = no access (will return empty) + ); + + return { data: serverList }; + } + ); + + /** + * POST /servers - Add a new server + * Encrypts the token before storage + */ + app.post( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = createServerSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { name, type, url, token } = body.data; + const authUser = request.user; + + // Only owners can add servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can add servers'); + } + + // Check if server already exists + const existing = await db + .select() + .from(servers) + .where(eq(servers.url, url)) + .limit(1); + + if (existing.length > 0) { + return reply.conflict('A server with this URL already exists'); + } + + // Verify the server connection + try { + if (type === 'plex') { + const isAdmin = await PlexClient.verifyServerAdmin(token, url); + if (!isAdmin) { + return reply.forbidden('Token does not have admin access to this Plex server'); + } + } else if (type === 'jellyfin') { + const isAdmin = await JellyfinClient.verifyServerAdmin(token, url); + if (!isAdmin) { + return reply.forbidden('Token does not have admin access to this Jellyfin server'); + } + } else if (type === 'emby') { + const isAdmin = await EmbyClient.verifyServerAdmin(token, url); + if (!isAdmin) { + return reply.forbidden('Token does not have admin access to this Emby server'); + } + } + } catch (error) { + app.log.error({ error }, 'Failed to verify server connection'); + return reply.badRequest('Failed to connect to server. Please verify URL and token.'); + } + + // Save server with plain text token (DB is localhost-only) + const inserted = await db + .insert(servers) + .values({ + name, + type, + url, + token, + }) + .returning({ + id: servers.id, + name: servers.name, + type: servers.type, + url: servers.url, + createdAt: servers.createdAt, + updatedAt: servers.updatedAt, + }); + + const server = inserted[0]; + if (!server) { + return reply.internalServerError('Failed to create server'); + } + + // Auto-sync users and libraries in background + syncServer(server.id, { syncUsers: true, syncLibraries: true }) + .then((result) => { + app.log.info({ serverId: server.id, usersAdded: result.usersAdded, librariesSynced: result.librariesSynced }, 'Auto-sync completed for new server'); + }) + .catch((error) => { + app.log.error({ error, serverId: server.id }, 'Auto-sync failed for new server'); + }); + + return reply.status(201).send(server); + } + ); + + /** + * DELETE /servers/:id - Remove a server + * Cascades to delete all related users, sessions, violations + */ + app.delete( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = serverIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid server ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can delete servers + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can delete servers'); + } + + // Check if server exists and user has access + const server = await db + .select() + .from(servers) + .where(eq(servers.id, id)) + .limit(1); + + if (server.length === 0) { + return reply.notFound('Server not found'); + } + + // Delete server (cascade will handle related records) + await db.delete(servers).where(eq(servers.id, id)); + + return { success: true }; + } + ); + + /** + * POST /servers/:id/sync - Force sync users and libraries from server + * For Plex: Fetches users from Plex.tv including shared users + * For Jellyfin: Fetches users from the server + */ + app.post( + '/:id/sync', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = serverIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid server ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can sync + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can sync servers'); + } + + // Check server exists + const serverRows = await db + .select() + .from(servers) + .where(eq(servers.id, id)) + .limit(1); + + if (serverRows.length === 0) { + return reply.notFound('Server not found'); + } + + try { + const result = await syncServer(id, { syncUsers: true, syncLibraries: true }); + + // Update server's updatedAt timestamp + await db + .update(servers) + .set({ updatedAt: new Date() }) + .where(eq(servers.id, id)); + + return { + success: result.errors.length === 0, + usersAdded: result.usersAdded, + usersUpdated: result.usersUpdated, + librariesSynced: result.librariesSynced, + errors: result.errors, + syncedAt: new Date().toISOString(), + }; + } catch (error) { + app.log.error({ error, serverId: id }, 'Failed to sync server'); + return reply.internalServerError('Failed to sync server'); + } + } + ); + + /** + * GET /servers/:id/statistics - Get server resource statistics (CPU, RAM) + * On-demand endpoint for dashboard - data is not stored + * Currently only supported for Plex servers (undocumented /statistics/resources endpoint) + */ + app.get( + '/:id/statistics', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = serverIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid server ID'); + } + + const { id } = params.data; + + // Get server with token + const serverRows = await db + .select() + .from(servers) + .where(eq(servers.id, id)) + .limit(1); + + const server = serverRows[0]; + if (!server) { + return reply.notFound('Server not found'); + } + + // Only Plex is supported for now (Jellyfin/Emby don't have equivalent endpoint) + if (server.type !== 'plex') { + return reply.badRequest('Server statistics are only available for Plex servers'); + } + + try { + const client = new PlexClient({ + url: server.url, + token: server.token, + }); + + const data = await client.getServerStatistics(SERVER_STATS_CONFIG.TIMESPAN_SECONDS); + + // DEBUG: Log what we got back + app.log.info({ serverId: id, dataLength: data.length, firstItem: data[0] }, 'Server statistics fetched'); + + return { + serverId: id, + data, + fetchedAt: new Date().toISOString(), + }; + } catch (error) { + app.log.error({ error, serverId: id }, 'Failed to fetch server statistics'); + return reply.internalServerError('Failed to fetch server statistics'); + } + } + ); + + /** + * GET /servers/:id/image/* - Proxy images from Plex/Jellyfin servers + * This endpoint fetches images without exposing server tokens to the client + * + * For Plex: /servers/:id/image/library/metadata/123/thumb/456 + * For Jellyfin: /servers/:id/image/Items/123/Images/Primary?tag=abc + * + * Note: Accepts auth via header OR query param (?token=xxx) since browser + * tags don't send Authorization headers + */ + app.get( + '/:id/image/*', + async (request, reply) => { + // Custom auth: try header first, fall back to query param for tags + const queryToken = (request.query as { token?: string }).token; + if (queryToken) { + // Manually set authorization header for jwtVerify to work + request.headers.authorization = `Bearer ${queryToken}`; + } + + try { + await request.jwtVerify(); + } catch { + return reply.unauthorized('Invalid or missing token'); + } + + const { id } = request.params as { id: string; '*': string }; + const imagePath = (request.params as { '*': string })['*']; + + if (!imagePath) { + return reply.badRequest('Image path is required'); + } + + // Get server with token + const serverRows = await db + .select() + .from(servers) + .where(eq(servers.id, id)) + .limit(1); + + const server = serverRows[0]; + if (!server) { + return reply.notFound('Server not found'); + } + + const baseUrl = server.url.replace(/\/$/, ''); + const token = server.token; + + try { + let imageUrl: string; + let headers: Record; + + if (server.type === 'plex') { + // Plex uses X-Plex-Token query param + const separator = imagePath.includes('?') ? '&' : '?'; + imageUrl = `${baseUrl}/${imagePath}${separator}X-Plex-Token=${token}`; + headers = { Accept: 'image/*' }; + } else { + // Jellyfin and Emby use X-Emby-Authorization header + imageUrl = `${baseUrl}/${imagePath}`; + headers = { + 'X-Emby-Authorization': `MediaBrowser Client="Tracearr", Device="Tracearr Server", DeviceId="tracearr-server", Version="1.0.0", Token="${token}"`, + Accept: 'image/*', + }; + } + + const response = await fetch(imageUrl, { headers }); + + if (!response.ok) { + return reply.notFound('Image not found'); + } + + const contentType = response.headers.get('content-type') ?? 'image/jpeg'; + const buffer = await response.arrayBuffer(); + + reply.header('Content-Type', contentType); + reply.header('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours + return reply.send(Buffer.from(buffer)); + } catch (error) { + app.log.error({ error, serverId: id, imagePath }, 'Failed to fetch image from server'); + return reply.internalServerError('Failed to fetch image'); + } + } + ); +}; diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts new file mode 100644 index 0000000..ab19281 --- /dev/null +++ b/apps/server/src/routes/sessions.ts @@ -0,0 +1,506 @@ +/** + * Session routes - Query historical and active sessions + * + * Activity history is grouped by reference_id to show unique "plays" rather than + * individual session records. Multiple pause/resume cycles for the same content + * are aggregated into a single row with combined duration. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, sql } from 'drizzle-orm'; +import { + sessionQuerySchema, + sessionIdParamSchema, + serverIdFilterSchema, + terminateSessionBodySchema, + REDIS_KEYS, + type ActiveSession, +} from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { sessions, serverUsers, servers } from '../db/schema.js'; +import { filterByServerAccess, hasServerAccess } from '../utils/serverFiltering.js'; +import { terminateSession } from '../services/termination.js'; +import { getCacheService } from '../services/cache.js'; + +export const sessionRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /sessions - Query historical sessions with pagination and filters + * + * Sessions are grouped by reference_id to show unique "plays". Multiple + * pause/resume cycles for the same content appear as one row with: + * - Aggregated duration (total watch time) + * - First session's start time + * - Last session's stop time + * - Segment count (how many pause/resume cycles) + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = sessionQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { + page = 1, + pageSize = 50, + serverUserId, + serverId, + state, + mediaType, + startDate, + endDate, + } = query.data; + + const authUser = request.user; + const offset = (page - 1) * pageSize; + + // Build WHERE clause conditions dynamically for raw SQL CTE query + // Note: Using sql.join() pattern because this query requires a CTE for reference_id grouping, + // which isn't expressible in Drizzle's query builder. + const conditions: ReturnType[] = []; + + // Filter by user's accessible servers (owners see all) + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + // No server access - return empty result + return { + data: [], + page, + pageSize, + total: 0, + totalPages: 0, + }; + } else if (authUser.serverIds.length === 1) { + conditions.push(sql`s.server_id = ${authUser.serverIds[0]}`); + } else { + // Multiple servers - use IN clause + const serverIdList = authUser.serverIds.map((id: string) => sql`${id}`); + conditions.push(sql`s.server_id IN (${sql.join(serverIdList, sql`, `)})`); + } + } + + if (serverUserId) { + conditions.push(sql`s.server_user_id = ${serverUserId}`); + } + + if (serverId) { + conditions.push(sql`s.server_id = ${serverId}`); + } + + if (state) { + conditions.push(sql`s.state = ${state}`); + } + + if (mediaType) { + conditions.push(sql`s.media_type = ${mediaType}`); + } + + if (startDate) { + conditions.push(sql`s.started_at >= ${startDate}`); + } + + if (endDate) { + conditions.push(sql`s.started_at <= ${endDate}`); + } + + // Build the WHERE clause + const whereClause = conditions.length > 0 + ? sql`WHERE ${sql.join(conditions, sql` AND `)}` + : sql``; + + // Query sessions grouped by reference_id (or id if no reference) + const result = await db.execute(sql` + WITH grouped_sessions AS ( + SELECT + COALESCE(s.reference_id, s.id) as play_id, + MIN(s.started_at) as started_at, + MAX(s.stopped_at) as stopped_at, + SUM(COALESCE(s.duration_ms, 0)) as duration_ms, + SUM(COALESCE(s.paused_duration_ms, 0)) as paused_duration_ms, + MAX(s.progress_ms) as progress_ms, + COUNT(*) as segment_count, + BOOL_OR(s.watched) as watched, + (array_agg(s.id ORDER BY s.started_at))[1] as first_session_id, + (array_agg(s.state ORDER BY s.started_at DESC))[1] as state + FROM sessions s + ${whereClause} + GROUP BY COALESCE(s.reference_id, s.id) + ORDER BY MIN(s.started_at) DESC + LIMIT ${pageSize} OFFSET ${offset} + ) + SELECT + gs.play_id as id, + gs.started_at, + gs.stopped_at, + gs.duration_ms, + gs.paused_duration_ms, + gs.progress_ms, + gs.segment_count, + gs.watched, + gs.state, + s.server_id, + sv.name as server_name, + sv.type as server_type, + s.server_user_id, + su.username, + su.thumb_url as user_thumb, + s.session_key, + s.media_type, + s.media_title, + s.grandparent_title, + s.season_number, + s.episode_number, + s.year, + s.thumb_path, + s.reference_id, + s.ip_address, + s.geo_city, + s.geo_region, + s.geo_country, + s.geo_lat, + s.geo_lon, + s.player_name, + s.device_id, + s.product, + s.device, + s.platform, + s.quality, + s.is_transcode, + s.bitrate + FROM grouped_sessions gs + JOIN sessions s ON s.id = gs.first_session_id + JOIN server_users su ON su.id = s.server_user_id + JOIN servers sv ON sv.id = s.server_id + ORDER BY gs.started_at DESC + `); + + // Type the result + const sessionData = (result.rows as { + id: string; + started_at: Date; + stopped_at: Date | null; + duration_ms: string | null; + paused_duration_ms: string | null; + progress_ms: number | null; + segment_count: string; + watched: boolean; + state: string; + server_id: string; + server_name: string; + server_type: string; + server_user_id: string; + username: string; + user_thumb: string | null; + session_key: string; + media_type: string; + media_title: string; + grandparent_title: string | null; + season_number: number | null; + episode_number: number | null; + year: number | null; + thumb_path: string | null; + reference_id: string | null; + ip_address: string | null; + geo_city: string | null; + geo_region: string | null; + geo_country: string | null; + geo_lat: number | null; + geo_lon: number | null; + player_name: string | null; + device_id: string | null; + product: string | null; + device: string | null; + platform: string | null; + quality: string | null; + is_transcode: boolean | null; + bitrate: number | null; + }[]).map((row) => ({ + id: row.id, + serverId: row.server_id, + serverName: row.server_name, + serverType: row.server_type, + serverUserId: row.server_user_id, + username: row.username, + userThumb: row.user_thumb, + sessionKey: row.session_key, + state: row.state, + mediaType: row.media_type, + mediaTitle: row.media_title, + grandparentTitle: row.grandparent_title, + seasonNumber: row.season_number, + episodeNumber: row.episode_number, + year: row.year, + thumbPath: row.thumb_path, + startedAt: row.started_at, + stoppedAt: row.stopped_at, + durationMs: row.duration_ms ? Number(row.duration_ms) : null, + pausedDurationMs: row.paused_duration_ms ? Number(row.paused_duration_ms) : null, + progressMs: row.progress_ms, + referenceId: row.reference_id, + watched: row.watched, + segmentCount: Number(row.segment_count), + ipAddress: row.ip_address, + geoCity: row.geo_city, + geoRegion: row.geo_region, + geoCountry: row.geo_country, + geoLat: row.geo_lat, + geoLon: row.geo_lon, + playerName: row.player_name, + deviceId: row.device_id, + product: row.product, + device: row.device, + platform: row.platform, + quality: row.quality, + isTranscode: row.is_transcode, + bitrate: row.bitrate, + })); + + // Get total count of unique plays + const countResult = await db.execute(sql` + SELECT COUNT(DISTINCT COALESCE(s.reference_id, s.id))::int as count + FROM sessions s + ${whereClause} + `); + const total = (countResult.rows[0] as { count: number })?.count ?? 0; + + return { + data: sessionData, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }; + } + ); + + /** + * GET /sessions/active - Get currently active streams from cache + */ + app.get( + '/active', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const authUser = request.user; + + // Parse optional serverId filter + const query = serverIdFilterSchema.safeParse(request.query); + const serverId = query.success ? query.data.serverId : undefined; + + // If specific server requested, validate access + if (serverId && !hasServerAccess(authUser, serverId)) { + return reply.forbidden('You do not have access to this server'); + } + + // Get active sessions from atomic SET-based cache + const cacheService = getCacheService(); + let activeSessions: ActiveSession[] = []; + + if (cacheService) { + activeSessions = await cacheService.getAllActiveSessions(); + } + + // Filter by specific server if requested + if (serverId) { + activeSessions = activeSessions.filter((s) => s.serverId === serverId); + } else { + // Otherwise filter by user's accessible servers (owners see all) + activeSessions = filterByServerAccess(activeSessions, authUser); + } + + return { data: activeSessions }; + } + ); + + /** + * GET /sessions/:id - Get detailed info for a specific session + */ + app.get( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = sessionIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid session ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Try cache first for active sessions + const cached = await app.redis.get(REDIS_KEYS.SESSION_BY_ID(id)); + if (cached) { + try { + const activeSession = JSON.parse(cached) as ActiveSession; + // Verify access (owners can see all servers) + if (hasServerAccess(authUser, activeSession.serverId)) { + // Transform ActiveSession to SessionWithDetails format for consistent API response + // ActiveSession has nested user/server objects, but clients expect flat structure + const { user, server, ...sessionFields } = activeSession; + return { + ...sessionFields, + username: user.username, + userThumb: user.thumbUrl, + serverName: server.name, + serverType: server.type, + }; + } + } catch { + // Fall through to DB + } + } + + // Query from database using manual JOINs + // Note: We use manual JOINs here instead of relational queries because: + // 1. The API expects a flat response shape (serverName, username vs nested objects) + // 2. Manual JOINs produce the exact shape without transformation + // 3. Type-safe via explicit select fields + // See drizzle-orm-research-findings.md for when to use relational vs manual JOINs + const sessionData = await db + .select({ + id: sessions.id, + serverId: sessions.serverId, + serverName: servers.name, + serverType: servers.type, + serverUserId: sessions.serverUserId, + username: serverUsers.username, + userThumb: serverUsers.thumbUrl, + sessionKey: sessions.sessionKey, + state: sessions.state, + mediaType: sessions.mediaType, + mediaTitle: sessions.mediaTitle, + // Enhanced media metadata + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + thumbPath: sessions.thumbPath, + startedAt: sessions.startedAt, + stoppedAt: sessions.stoppedAt, + durationMs: sessions.durationMs, + progressMs: sessions.progressMs, + totalDurationMs: sessions.totalDurationMs, + // Pause tracking fields + lastPausedAt: sessions.lastPausedAt, + pausedDurationMs: sessions.pausedDurationMs, + referenceId: sessions.referenceId, + watched: sessions.watched, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + deviceId: sessions.deviceId, + product: sessions.product, + device: sessions.device, + platform: sessions.platform, + quality: sessions.quality, + isTranscode: sessions.isTranscode, + bitrate: sessions.bitrate, + }) + .from(sessions) + .innerJoin(serverUsers, eq(sessions.serverUserId, serverUsers.id)) + .innerJoin(servers, eq(sessions.serverId, servers.id)) + .where(eq(sessions.id, id)) + .limit(1); + + const session = sessionData[0]; + if (!session) { + return reply.notFound('Session not found'); + } + + // Verify access (owners can see all servers) + if (!hasServerAccess(authUser, session.serverId)) { + return reply.forbidden('You do not have access to this session'); + } + + return session; + } + ); + + /** + * POST /sessions/:id/terminate - Terminate a playback session + * + * Requires admin access. Sends a stop command to the media server + * and logs the termination for auditing. + */ + app.post( + '/:id/terminate', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = sessionIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid session ID'); + } + + const body = terminateSessionBodySchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { id } = params.data; + const { reason } = body.data; + const authUser = request.user; + + // Only admins and owners can terminate sessions + if (authUser.role !== 'owner' && authUser.role !== 'admin') { + return reply.forbidden('Only administrators can terminate sessions'); + } + + // Verify the session exists and user has access to its server + const session = await db + .select({ + id: sessions.id, + serverId: sessions.serverId, + serverUserId: sessions.serverUserId, + state: sessions.state, + }) + .from(sessions) + .where(eq(sessions.id, id)) + .limit(1); + + const sessionData = session[0]; + if (!sessionData) { + return reply.notFound('Session not found'); + } + + if (!hasServerAccess(authUser, sessionData.serverId)) { + return reply.forbidden('You do not have access to this server'); + } + + // Check if session is already stopped + if (sessionData.state === 'stopped') { + return reply.conflict('Session has already ended'); + } + + // Attempt termination + const result = await terminateSession({ + sessionId: id, + trigger: 'manual', + triggeredByUserId: authUser.userId, + reason, + }); + + if (!result.success) { + app.log.error( + { sessionId: id, error: result.error, terminationLogId: result.terminationLogId }, + 'Failed to terminate session' + ); + return reply.code(500).send({ + success: false, + error: result.error, + terminationLogId: result.terminationLogId, + }); + } + + return { + success: true, + terminationLogId: result.terminationLogId, + message: 'Stream termination command sent successfully', + }; + } + ); +}; diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts new file mode 100644 index 0000000..bbbd21d --- /dev/null +++ b/apps/server/src/routes/settings.ts @@ -0,0 +1,416 @@ +/** + * Settings routes - Application configuration + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { updateSettingsSchema, type Settings } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { settings } from '../db/schema.js'; + +// Default settings row ID (singleton pattern) +const SETTINGS_ID = 1; + +export const settingsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /settings - Get application settings + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const authUser = request.user; + + // Only owners can view settings + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can view settings'); + } + + // Get or create settings + // First try to get settings - if primaryAuthMethod column doesn't exist, this will fail + let settingsRow; + let primaryAuthMethod: 'jellyfin' | 'local' = 'local'; + + try { + // Try full select including primaryAuthMethod + settingsRow = await db + .select() + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + // If we got here, column exists - extract the value + const row = settingsRow[0]; + if (row && 'primaryAuthMethod' in row && row.primaryAuthMethod) { + primaryAuthMethod = row.primaryAuthMethod; + } + } catch { + // Column doesn't exist yet - select without primaryAuthMethod + // We need to explicitly select each column + settingsRow = await db + .select({ + id: settings.id, + allowGuestAccess: settings.allowGuestAccess, + unitSystem: settings.unitSystem, + discordWebhookUrl: settings.discordWebhookUrl, + customWebhookUrl: settings.customWebhookUrl, + webhookFormat: settings.webhookFormat, + ntfyTopic: settings.ntfyTopic, + pollerEnabled: settings.pollerEnabled, + pollerIntervalMs: settings.pollerIntervalMs, + tautulliUrl: settings.tautulliUrl, + tautulliApiKey: settings.tautulliApiKey, + externalUrl: settings.externalUrl, + basePath: settings.basePath, + trustProxy: settings.trustProxy, + mobileEnabled: settings.mobileEnabled, + updatedAt: settings.updatedAt, + }) + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + // Use default since column doesn't exist + primaryAuthMethod = 'local'; + } + + // Create default settings if not exists + if (settingsRow.length === 0) { + try { + const inserted = await db + .insert(settings) + .values({ + id: SETTINGS_ID, + allowGuestAccess: false, + primaryAuthMethod: 'local', + }) + .returning(); + settingsRow = inserted; + } catch { + // Column doesn't exist - insert without primaryAuthMethod + const inserted = await db + .insert(settings) + .values({ + id: SETTINGS_ID, + allowGuestAccess: false, + }) + .returning(); + settingsRow = inserted; + } + } + + const row = settingsRow[0]; + if (!row) { + return reply.internalServerError('Failed to load settings'); + } + + const result: Settings = { + allowGuestAccess: row.allowGuestAccess, + unitSystem: row.unitSystem, + discordWebhookUrl: row.discordWebhookUrl, + customWebhookUrl: row.customWebhookUrl, + webhookFormat: row.webhookFormat, + ntfyTopic: row.ntfyTopic, + pollerEnabled: row.pollerEnabled, + pollerIntervalMs: row.pollerIntervalMs, + tautulliUrl: row.tautulliUrl, + tautulliApiKey: row.tautulliApiKey ? '********' : null, // Mask API key + externalUrl: row.externalUrl, + basePath: row.basePath, + trustProxy: row.trustProxy, + mobileEnabled: row.mobileEnabled, + primaryAuthMethod, + }; + + return result; + } + ); + + /** + * PATCH /settings - Update application settings + */ + app.patch( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const body = updateSettingsSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const authUser = request.user; + + // Only owners can update settings + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can update settings'); + } + + // Build update object + const updateData: Partial<{ + allowGuestAccess: boolean; + unitSystem: 'metric' | 'imperial'; + discordWebhookUrl: string | null; + customWebhookUrl: string | null; + webhookFormat: 'json' | 'ntfy' | 'apprise' | null; + ntfyTopic: string | null; + pollerEnabled: boolean; + pollerIntervalMs: number; + tautulliUrl: string | null; + tautulliApiKey: string | null; + externalUrl: string | null; + basePath: string; + trustProxy: boolean; + primaryAuthMethod: 'jellyfin' | 'local'; + updatedAt: Date; + }> = { + updatedAt: new Date(), + }; + + if (body.data.allowGuestAccess !== undefined) { + updateData.allowGuestAccess = body.data.allowGuestAccess; + } + + if (body.data.unitSystem !== undefined) { + updateData.unitSystem = body.data.unitSystem; + } + + if (body.data.discordWebhookUrl !== undefined) { + updateData.discordWebhookUrl = body.data.discordWebhookUrl; + } + + if (body.data.customWebhookUrl !== undefined) { + updateData.customWebhookUrl = body.data.customWebhookUrl; + } + + if (body.data.webhookFormat !== undefined) { + updateData.webhookFormat = body.data.webhookFormat; + } + + if (body.data.ntfyTopic !== undefined) { + updateData.ntfyTopic = body.data.ntfyTopic; + } + + if (body.data.pollerEnabled !== undefined) { + updateData.pollerEnabled = body.data.pollerEnabled; + } + + if (body.data.pollerIntervalMs !== undefined) { + updateData.pollerIntervalMs = body.data.pollerIntervalMs; + } + + if (body.data.tautulliUrl !== undefined) { + updateData.tautulliUrl = body.data.tautulliUrl; + } + + if (body.data.tautulliApiKey !== undefined) { + // Store API key as-is (could encrypt if needed) + updateData.tautulliApiKey = body.data.tautulliApiKey; + } + + if (body.data.externalUrl !== undefined) { + // Strip trailing slash for consistency + updateData.externalUrl = body.data.externalUrl?.replace(/\/+$/, '') ?? null; + } + + if (body.data.basePath !== undefined) { + // Normalize base path: ensure leading slash, no trailing slash + let path = body.data.basePath.trim(); + if (path && !path.startsWith('/')) { + path = '/' + path; + } + path = path.replace(/\/+$/, ''); + updateData.basePath = path; + } + + if (body.data.trustProxy !== undefined) { + updateData.trustProxy = body.data.trustProxy; + } + + if (body.data.primaryAuthMethod !== undefined) { + updateData.primaryAuthMethod = body.data.primaryAuthMethod; + } + + // Ensure settings row exists + const existing = await db + .select() + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + if (existing.length === 0) { + // Create with provided values - use full updateData with defaults for required fields + // Note: mobileEnabled is not in updateData, so it will use the database default (false) + await db.insert(settings).values({ + id: SETTINGS_ID, + allowGuestAccess: updateData.allowGuestAccess ?? false, + discordWebhookUrl: updateData.discordWebhookUrl ?? null, + customWebhookUrl: updateData.customWebhookUrl ?? null, + webhookFormat: updateData.webhookFormat ?? null, + ntfyTopic: updateData.ntfyTopic ?? null, + pollerEnabled: updateData.pollerEnabled ?? true, + pollerIntervalMs: updateData.pollerIntervalMs ?? 15000, + tautulliUrl: updateData.tautulliUrl ?? null, + tautulliApiKey: updateData.tautulliApiKey ?? null, + externalUrl: updateData.externalUrl ?? null, + basePath: updateData.basePath ?? '', + trustProxy: updateData.trustProxy ?? false, + primaryAuthMethod: updateData.primaryAuthMethod ?? 'local', + }); + } else { + // Update existing + await db + .update(settings) + .set(updateData) + .where(eq(settings.id, SETTINGS_ID)); + } + + // Return updated settings + const updated = await db + .select() + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + const row = updated[0]; + if (!row) { + return reply.internalServerError('Failed to update settings'); + } + + // Handle case where primaryAuthMethod column might not exist yet (before migration) + let primaryAuthMethod: 'jellyfin' | 'local' = 'local'; + if ('primaryAuthMethod' in row && row.primaryAuthMethod) { + primaryAuthMethod = row.primaryAuthMethod; + } + + const result: Settings = { + allowGuestAccess: row.allowGuestAccess, + unitSystem: row.unitSystem, + discordWebhookUrl: row.discordWebhookUrl, + customWebhookUrl: row.customWebhookUrl, + webhookFormat: row.webhookFormat, + ntfyTopic: row.ntfyTopic, + pollerEnabled: row.pollerEnabled, + pollerIntervalMs: row.pollerIntervalMs, + tautulliUrl: row.tautulliUrl, + tautulliApiKey: row.tautulliApiKey ? '********' : null, // Mask API key + externalUrl: row.externalUrl, + basePath: row.basePath, + trustProxy: row.trustProxy, + mobileEnabled: row.mobileEnabled, + primaryAuthMethod, + }; + + return result; + } + ); +}; + +/** + * Get poller settings from database (for internal use by poller) + */ +export async function getPollerSettings(): Promise<{ enabled: boolean; intervalMs: number }> { + const row = await db + .select({ + pollerEnabled: settings.pollerEnabled, + pollerIntervalMs: settings.pollerIntervalMs, + }) + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + const settingsRow = row[0]; + if (!settingsRow) { + // Return defaults if settings don't exist yet + return { enabled: true, intervalMs: 15000 }; + } + + return { + enabled: settingsRow.pollerEnabled, + intervalMs: settingsRow.pollerIntervalMs, + }; +} + +/** + * Get network settings from database (for internal use) + */ +export async function getNetworkSettings(): Promise<{ + externalUrl: string | null; + basePath: string; + trustProxy: boolean; +}> { + const row = await db + .select({ + externalUrl: settings.externalUrl, + basePath: settings.basePath, + trustProxy: settings.trustProxy, + }) + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + const settingsRow = row[0]; + if (!settingsRow) { + // Return defaults if settings don't exist yet + return { externalUrl: null, basePath: '', trustProxy: false }; + } + + return { + externalUrl: settingsRow.externalUrl, + basePath: settingsRow.basePath, + trustProxy: settingsRow.trustProxy, + }; +} + +/** + * Notification settings for internal use by NotificationDispatcher + */ +export interface NotificationSettings { + discordWebhookUrl: string | null; + customWebhookUrl: string | null; + webhookFormat: 'json' | 'ntfy' | 'apprise' | null; + ntfyTopic: string | null; + webhookSecret: string | null; + mobileEnabled: boolean; + unitSystem: 'metric' | 'imperial'; +} + +/** + * Get notification settings from database (for internal use by notification dispatcher) + */ +export async function getNotificationSettings(): Promise { + const row = await db + .select({ + discordWebhookUrl: settings.discordWebhookUrl, + customWebhookUrl: settings.customWebhookUrl, + webhookFormat: settings.webhookFormat, + ntfyTopic: settings.ntfyTopic, + mobileEnabled: settings.mobileEnabled, + unitSystem: settings.unitSystem, + }) + .from(settings) + .where(eq(settings.id, SETTINGS_ID)) + .limit(1); + + const settingsRow = row[0]; + if (!settingsRow) { + // Return defaults if settings don't exist yet + return { + discordWebhookUrl: null, + customWebhookUrl: null, + webhookFormat: null, + ntfyTopic: null, + webhookSecret: null, + mobileEnabled: false, + unitSystem: 'metric', + }; + } + + return { + discordWebhookUrl: settingsRow.discordWebhookUrl, + customWebhookUrl: settingsRow.customWebhookUrl, + webhookFormat: settingsRow.webhookFormat, + ntfyTopic: settingsRow.ntfyTopic, + webhookSecret: null, // TODO: Add webhookSecret column to settings table in Phase 4 + mobileEnabled: settingsRow.mobileEnabled, + unitSystem: settingsRow.unitSystem, + }; +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts new file mode 100644 index 0000000..945a687 --- /dev/null +++ b/apps/server/src/routes/setup.ts @@ -0,0 +1,54 @@ +/** + * Setup routes - Check if Tracearr has been configured + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { isNotNull, eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { servers, users, settings } from '../db/schema.js'; + +export const setupRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /setup/status - Check Tracearr configuration status + * + * This endpoint is public (no auth required) so the frontend + * can determine whether to show the setup wizard or login page. + * + * Returns: + * - needsSetup: true if no owner accounts exist + * - hasServers: true if at least one server is configured + * - hasPasswordAuth: true if at least one user has password login enabled + */ + app.get('/status', async () => { + // Check for servers and users in parallel + const [serverList, jellyfinServerList, ownerList, passwordUserList] = await Promise.all([ + db.select({ id: servers.id }).from(servers).limit(1), + db.select({ id: servers.id }).from(servers).where(eq(servers.type, 'jellyfin')).limit(1), + db.select({ id: users.id }).from(users).where(eq(users.role, 'owner')).limit(1), + db.select({ id: users.id }).from(users).where(isNotNull(users.passwordHash)).limit(1), + ]); + + // Try to get primaryAuthMethod from settings, but handle case where column doesn't exist yet + let primaryAuthMethod: 'jellyfin' | 'local' = 'local'; + try { + const settingsRow = await db + .select({ primaryAuthMethod: settings.primaryAuthMethod }) + .from(settings) + .limit(1); + if (settingsRow[0]?.primaryAuthMethod) { + primaryAuthMethod = settingsRow[0].primaryAuthMethod; + } + } catch { + // Column doesn't exist yet (migration not run) - use default + primaryAuthMethod = 'local'; + } + + return { + needsSetup: ownerList.length === 0, + hasServers: serverList.length > 0, + hasJellyfinServers: jellyfinServerList.length > 0, + hasPasswordAuth: passwordUserList.length > 0, + primaryAuthMethod, + }; + }); +}; diff --git a/apps/server/src/routes/stats/__tests__/utils.test.ts b/apps/server/src/routes/stats/__tests__/utils.test.ts new file mode 100644 index 0000000..3a856fa --- /dev/null +++ b/apps/server/src/routes/stats/__tests__/utils.test.ts @@ -0,0 +1,342 @@ +/** + * Stats Route Utilities Tests + * + * Tests pure utility functions from routes/stats/utils.ts: + * - resolveDateRange: Calculate date range based on period and optional custom dates + * - getDateRange: (deprecated) Calculate start date based on period string + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-deprecated +import { getDateRange, resolveDateRange, buildDateRangeFilter, resetCachedState, hasAggregates, hasHyperLogLog } from '../utils.js'; + +// Mock the database module +vi.mock('../../../db/client.js', () => ({ + db: { + execute: vi.fn(), + }, +})); + +vi.mock('../../../db/timescale.js', () => ({ + getTimescaleStatus: vi.fn(), +})); + +describe('resolveDateRange', () => { + beforeEach(() => { + // Fix time to 2024-06-15 12:00:00 UTC for predictable tests + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('preset periods', () => { + it('should return start 1 day ago for "day" period', () => { + const result = resolveDateRange('day'); + expect(result.start).toEqual(new Date('2024-06-14T12:00:00Z')); + expect(result.end).toEqual(new Date('2024-06-15T12:00:00Z')); + }); + + it('should return start 7 days ago for "week" period', () => { + const result = resolveDateRange('week'); + expect(result.start).toEqual(new Date('2024-06-08T12:00:00Z')); + expect(result.end).toEqual(new Date('2024-06-15T12:00:00Z')); + }); + + it('should return start 30 days ago for "month" period', () => { + const result = resolveDateRange('month'); + expect(result.start).toEqual(new Date('2024-05-16T12:00:00Z')); + expect(result.end).toEqual(new Date('2024-06-15T12:00:00Z')); + }); + + it('should return start 365 days ago for "year" period', () => { + const result = resolveDateRange('year'); + expect(result.start).toEqual(new Date('2023-06-16T12:00:00Z')); + expect(result.end).toEqual(new Date('2024-06-15T12:00:00Z')); + }); + }); + + describe('all-time period', () => { + it('should return null start for "all" period', () => { + const result = resolveDateRange('all'); + expect(result.start).toBeNull(); + expect(result.end).toEqual(new Date('2024-06-15T12:00:00Z')); + }); + }); + + describe('custom period', () => { + it('should use provided start and end dates', () => { + const result = resolveDateRange( + 'custom', + '2024-01-01T00:00:00Z', + '2024-01-31T23:59:59Z' + ); + expect(result.start).toEqual(new Date('2024-01-01T00:00:00Z')); + expect(result.end).toEqual(new Date('2024-01-31T23:59:59Z')); + }); + + it('should throw if custom period missing startDate', () => { + expect(() => resolveDateRange('custom', undefined, '2024-01-31T00:00:00Z')) + .toThrow('Custom period requires startDate and endDate'); + }); + + it('should throw if custom period missing endDate', () => { + expect(() => resolveDateRange('custom', '2024-01-01T00:00:00Z', undefined)) + .toThrow('Custom period requires startDate and endDate'); + }); + }); +}); + +// Tests for deprecated getDateRange (kept for backwards compatibility) +/* eslint-disable @typescript-eslint/no-deprecated */ +describe('getDateRange (deprecated)', () => { + beforeEach(() => { + // Fix time to 2024-06-15 12:00:00 UTC for predictable tests + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('period calculations', () => { + it('should return date 1 day ago for "day" period', () => { + const result = getDateRange('day'); + expect(result).toEqual(new Date('2024-06-14T12:00:00Z')); + }); + + it('should return date 7 days ago for "week" period', () => { + const result = getDateRange('week'); + expect(result).toEqual(new Date('2024-06-08T12:00:00Z')); + }); + + it('should return date 30 days ago for "month" period', () => { + const result = getDateRange('month'); + expect(result).toEqual(new Date('2024-05-16T12:00:00Z')); + }); + + it('should return date 365 days ago for "year" period', () => { + const result = getDateRange('year'); + expect(result).toEqual(new Date('2023-06-16T12:00:00Z')); + }); + }); + + describe('return type', () => { + it('should return a Date object', () => { + const result = getDateRange('day'); + expect(result).toBeInstanceOf(Date); + }); + }); + + describe('relative time correctness', () => { + it('should calculate milliseconds correctly for day', () => { + const now = new Date('2024-06-15T12:00:00Z'); + const result = getDateRange('day'); + const diffMs = now.getTime() - result.getTime(); + expect(diffMs).toBe(24 * 60 * 60 * 1000); // 1 day in ms + }); + + it('should calculate milliseconds correctly for week', () => { + const now = new Date('2024-06-15T12:00:00Z'); + const result = getDateRange('week'); + const diffMs = now.getTime() - result.getTime(); + expect(diffMs).toBe(7 * 24 * 60 * 60 * 1000); // 7 days in ms + }); + + it('should calculate milliseconds correctly for month', () => { + const now = new Date('2024-06-15T12:00:00Z'); + const result = getDateRange('month'); + const diffMs = now.getTime() - result.getTime(); + expect(diffMs).toBe(30 * 24 * 60 * 60 * 1000); // 30 days in ms + }); + + it('should calculate milliseconds correctly for year', () => { + const now = new Date('2024-06-15T12:00:00Z'); + const result = getDateRange('year'); + const diffMs = now.getTime() - result.getTime(); + expect(diffMs).toBe(365 * 24 * 60 * 60 * 1000); // 365 days in ms + }); + }); +}); +/* eslint-enable @typescript-eslint/no-deprecated */ + +describe('buildDateRangeFilter', () => { + // Helper to extract SQL string parts from drizzle sql template result + // Drizzle uses StringChunk objects with value arrays for string parts + const getSqlStrings = (sqlResult: ReturnType) => { + return sqlResult.queryChunks + .map((chunk) => { + // StringChunk has { value: string[] } + if (chunk && typeof chunk === 'object' && 'value' in chunk) { + return (chunk as { value: string[] }).value.join(''); + } + return ''; + }) + .join(''); + }; + + it('should return empty SQL for null start (all-time)', () => { + const range = { start: null, end: new Date('2024-06-15T12:00:00Z') }; + const result = buildDateRangeFilter(range); + const sqlStrings = getSqlStrings(result); + expect(sqlStrings.trim()).toBe(''); + }); + + it('should return lower bound only for preset periods', () => { + const start = new Date('2024-06-14T12:00:00Z'); + const end = new Date('2024-06-15T12:00:00Z'); + const range = { start, end }; + const result = buildDateRangeFilter(range); + const sqlStrings = getSqlStrings(result); + expect(sqlStrings).toContain('AND started_at >='); + expect(sqlStrings).not.toContain('AND started_at <'); + }); + + it('should return both bounds when includeEndBound is true', () => { + const start = new Date('2024-01-01T00:00:00Z'); + const end = new Date('2024-01-31T23:59:59Z'); + const range = { start, end }; + const result = buildDateRangeFilter(range, true); + const sqlStrings = getSqlStrings(result); + expect(sqlStrings).toContain('AND started_at >='); + expect(sqlStrings).toContain('AND started_at <'); + }); + + it('should work with resolveDateRange output for week', () => { + const range = resolveDateRange('week'); + const result = buildDateRangeFilter(range); + const sqlStrings = getSqlStrings(result); + expect(sqlStrings).toContain('AND started_at >='); + }); + + it('should work with all-time from resolveDateRange', () => { + const range = resolveDateRange('all'); + const result = buildDateRangeFilter(range); + const sqlStrings = getSqlStrings(result); + expect(sqlStrings.trim()).toBe(''); + }); +}); + +describe('resetCachedState', () => { + it('should reset cached state without error', () => { + // This function resets internal cache variables - just verify it runs + expect(() => resetCachedState()).not.toThrow(); + }); + + it('should be callable multiple times', () => { + // Should be safe to call multiple times + resetCachedState(); + resetCachedState(); + expect(() => resetCachedState()).not.toThrow(); + }); +}); + +describe('hasAggregates', () => { + beforeEach(() => { + resetCachedState(); + vi.clearAllMocks(); + }); + + it('should return true when 3+ aggregates exist', async () => { + const { getTimescaleStatus } = await import('../../../db/timescale.js'); + vi.mocked(getTimescaleStatus).mockResolvedValueOnce({ + extensionInstalled: true, + sessionsIsHypertable: true, + compressionEnabled: false, + continuousAggregates: ['agg1', 'agg2', 'agg3'], + chunkCount: 0, + }); + const result = await hasAggregates(); + expect(result).toBe(true); + }); + + it('should return false when fewer than 3 aggregates', async () => { + const { getTimescaleStatus } = await import('../../../db/timescale.js'); + vi.mocked(getTimescaleStatus).mockResolvedValueOnce({ + extensionInstalled: true, + sessionsIsHypertable: true, + compressionEnabled: false, + continuousAggregates: ['agg1'], + chunkCount: 0, + }); + const result = await hasAggregates(); + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + const { getTimescaleStatus } = await import('../../../db/timescale.js'); + vi.mocked(getTimescaleStatus).mockRejectedValueOnce(new Error('DB error')); + const result = await hasAggregates(); + expect(result).toBe(false); + }); + + it('should cache the result', async () => { + const { getTimescaleStatus } = await import('../../../db/timescale.js'); + vi.mocked(getTimescaleStatus).mockResolvedValueOnce({ + extensionInstalled: true, + sessionsIsHypertable: true, + compressionEnabled: false, + continuousAggregates: ['agg1', 'agg2', 'agg3'], + chunkCount: 0, + }); + await hasAggregates(); + await hasAggregates(); + // Should only call getTimescaleStatus once due to caching + expect(getTimescaleStatus).toHaveBeenCalledTimes(1); + }); +}); + +describe('hasHyperLogLog', () => { + beforeEach(() => { + resetCachedState(); + vi.clearAllMocks(); + }); + + it('should return true when extension and column exist', async () => { + const { db } = await import('../../../db/client.js'); + vi.mocked(db.execute).mockResolvedValueOnce({ + rows: [{ extension_installed: true, hll_column_exists: true }], + } as never); + const result = await hasHyperLogLog(); + expect(result).toBe(true); + }); + + it('should return false when extension missing', async () => { + const { db } = await import('../../../db/client.js'); + vi.mocked(db.execute).mockResolvedValueOnce({ + rows: [{ extension_installed: false, hll_column_exists: true }], + } as never); + const result = await hasHyperLogLog(); + expect(result).toBe(false); + }); + + it('should return false when column missing', async () => { + const { db } = await import('../../../db/client.js'); + vi.mocked(db.execute).mockResolvedValueOnce({ + rows: [{ extension_installed: true, hll_column_exists: false }], + } as never); + const result = await hasHyperLogLog(); + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + const { db } = await import('../../../db/client.js'); + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB error')); + const result = await hasHyperLogLog(); + expect(result).toBe(false); + }); + + it('should cache the result', async () => { + const { db } = await import('../../../db/client.js'); + vi.mocked(db.execute).mockResolvedValueOnce({ + rows: [{ extension_installed: true, hll_column_exists: true }], + } as never); + await hasHyperLogLog(); + await hasHyperLogLog(); + // Should only call db.execute once due to caching + expect(db.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/routes/stats/content.ts b/apps/server/src/routes/stats/content.ts new file mode 100644 index 0000000..b30c018 --- /dev/null +++ b/apps/server/src/routes/stats/content.ts @@ -0,0 +1,177 @@ +/** + * Content Statistics Routes + * + * GET /top-content - Top movies and shows by play count + * GET /libraries - Library counts (placeholder) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { statsQuerySchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { resolveDateRange } from './utils.js'; +import { validateServerAccess } from '../../utils/serverFiltering.js'; + +/** + * Build SQL server filter fragment for raw queries + */ +function buildServerFilterSql( + serverId: string | undefined, + authUser: { role: string; serverIds: string[] } +): ReturnType { + if (serverId) { + return sql`AND server_id = ${serverId}`; + } + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + return sql`AND false`; + } else if (authUser.serverIds.length === 1) { + return sql`AND server_id = ${authUser.serverIds[0]}`; + } else { + const serverIdList = authUser.serverIds.map(id => sql`${id}`); + return sql`AND server_id IN (${sql.join(serverIdList, sql`, `)})`; + } + } + return sql``; +} + +export const contentRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /top-content - Top movies and shows by play count + * + * Returns separate arrays for movies and TV shows: + * - Movies: Grouped by movie title + * - Shows: Aggregated by series (grandparent_title), counting total episode plays + */ + app.get( + '/top-content', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const startDateFilter = dateRange.start + ? sql`started_at >= ${dateRange.start}` + : sql`true`; + const customEndFilter = period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``; + + // Run both queries in parallel for better performance + const [moviesResult, showsResult] = await Promise.all([ + // Query top movies (media_type = 'movie') + db.execute(sql` + SELECT + media_title, + year, + COUNT(DISTINCT COALESCE(reference_id, id))::int as play_count, + COALESCE(SUM(duration_ms), 0)::bigint as total_watch_ms, + MAX(thumb_path) as thumb_path, + MAX(server_id::text) as server_id, + MAX(rating_key) as rating_key + FROM sessions + WHERE ${startDateFilter} AND media_type = 'movie' + ${customEndFilter} + ${serverFilter} + GROUP BY media_title, year + ORDER BY play_count DESC + LIMIT 10 + `), + // Query top TV shows (aggregate by series using grandparent_title) + db.execute(sql` + SELECT + grandparent_title, + MAX(year) as year, + COUNT(DISTINCT COALESCE(reference_id, id))::int as play_count, + COUNT(DISTINCT media_title)::int as episode_count, + COALESCE(SUM(duration_ms), 0)::bigint as total_watch_ms, + MAX(thumb_path) as thumb_path, + MAX(server_id::text) as server_id, + MAX(rating_key) as rating_key + FROM sessions + WHERE ${startDateFilter} AND media_type = 'episode' AND grandparent_title IS NOT NULL + ${customEndFilter} + ${serverFilter} + GROUP BY grandparent_title + ORDER BY play_count DESC + LIMIT 10 + `), + ]); + + const movies = (moviesResult.rows as { + media_title: string; + year: number | null; + play_count: number; + total_watch_ms: string; + thumb_path: string | null; + server_id: string | null; + rating_key: string | null; + }[]).map((m) => ({ + title: m.media_title, + type: 'movie' as const, + year: m.year, + playCount: m.play_count, + watchTimeHours: Math.round((Number(m.total_watch_ms) / (1000 * 60 * 60)) * 10) / 10, + thumbPath: m.thumb_path, + serverId: m.server_id, + ratingKey: m.rating_key, + })); + + const shows = (showsResult.rows as { + grandparent_title: string; + year: number | null; + play_count: number; + episode_count: number; + total_watch_ms: string; + thumb_path: string | null; + server_id: string | null; + rating_key: string | null; + }[]).map((s) => ({ + title: s.grandparent_title, // Series name + type: 'episode' as const, + year: s.year, + playCount: s.play_count, + episodeCount: s.episode_count, // Number of unique episodes watched + watchTimeHours: Math.round((Number(s.total_watch_ms) / (1000 * 60 * 60)) * 10) / 10, + thumbPath: s.thumb_path, + serverId: s.server_id, + ratingKey: s.rating_key, + })); + + return { movies, shows }; + } + ); + + /** + * GET /libraries - Library counts (placeholder - would need library sync) + */ + app.get( + '/libraries', + { preHandler: [app.authenticate] }, + async () => { + // In a real implementation, we'd sync library counts from servers + // For now, return a placeholder + return { + movies: 0, + shows: 0, + episodes: 0, + tracks: 0, + }; + } + ); +}; diff --git a/apps/server/src/routes/stats/dashboard.ts b/apps/server/src/routes/stats/dashboard.ts new file mode 100644 index 0000000..6ae0c2f --- /dev/null +++ b/apps/server/src/routes/stats/dashboard.ts @@ -0,0 +1,200 @@ +/** + * Dashboard Statistics Route + * + * GET /dashboard - Dashboard summary metrics (active streams, plays, watch time, alerts) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, gte, sql, and } from 'drizzle-orm'; +import { REDIS_KEYS, TIME_MS, type DashboardStats, serverIdFilterSchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { sessions } from '../../db/schema.js'; +import { + playsCountSince, + watchTimeSince, + violationsCountSince, + uniqueUsersSince, +} from '../../db/prepared.js'; +import { filterByServerAccess, validateServerAccess, buildServerAccessCondition } from '../../utils/serverFiltering.js'; +import { getCacheService } from '../../services/cache.js'; + +export const dashboardRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /dashboard - Dashboard summary metrics + * + * Query params: + * - serverId: Optional UUID to filter stats to a specific server + */ + app.get( + '/dashboard', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = serverIdFilterSchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { serverId } = query.data; + const authUser = request.user; + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + // Build cache key (server-specific or global) + const cacheKey = serverId + ? `${REDIS_KEYS.DASHBOARD_STATS}:${serverId}` + : REDIS_KEYS.DASHBOARD_STATS; + + // Try cache first + const cached = await app.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as DashboardStats; + } catch { + // Fall through to compute + } + } + + // Get active streams count - filter by server access + let activeStreams = 0; + const cacheService = getCacheService(); + if (cacheService) { + try { + let activeSessions = await cacheService.getAllActiveSessions(); + // Filter by user's accessible servers + activeSessions = filterByServerAccess(activeSessions, authUser); + // If specific server requested, filter further + if (serverId) { + activeSessions = activeSessions.filter(s => s.serverId === serverId); + } + activeStreams = activeSessions.length; + } catch { + // Ignore cache errors - activeStreams stays 0 + } + } + + // Get today's plays and watch time + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const last24h = new Date(Date.now() - TIME_MS.DAY); + + // If no serverId and user is owner, use prepared statements for performance + // Otherwise, use dynamic queries with server filtering + let todayPlays: number; + let watchTimeHours: number; + let alertsLast24h: number; + let activeUsersToday: number; + + if (!serverId && authUser.role === 'owner') { + // Owner with no filter - use prepared statements (fastest) + const [todayPlaysResult, watchTimeResult, alertsResult, activeUsersResult] = await Promise.all([ + playsCountSince.execute({ since: todayStart }), + watchTimeSince.execute({ since: todayStart }), + violationsCountSince.execute({ since: last24h }), + uniqueUsersSince.execute({ since: todayStart }), + ]); + + todayPlays = todayPlaysResult[0]?.count ?? 0; + watchTimeHours = Math.round( + (Number(watchTimeResult[0]?.totalMs ?? 0) / (1000 * 60 * 60)) * 10 + ) / 10; + alertsLast24h = alertsResult[0]?.count ?? 0; + activeUsersToday = activeUsersResult[0]?.count ?? 0; + } else { + // Build server filter conditions for dynamic queries + const buildSessionConditions = (since: Date) => { + const conditions = [gte(sessions.startedAt, since)]; + + if (serverId) { + // Specific server requested + conditions.push(eq(sessions.serverId, serverId)); + } else if (authUser.role !== 'owner') { + // Non-owner needs server access filter + const serverCondition = buildServerAccessCondition(authUser, sessions.serverId); + if (serverCondition) { + conditions.push(serverCondition); + } + } + + return conditions; + }; + + // Build server filter SQL for violations (via serverUsers join) + const buildViolationServerFilter = () => { + if (serverId) { + return sql`AND su.server_id = ${serverId}`; + } + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + return sql`AND false`; + } else if (authUser.serverIds.length === 1) { + return sql`AND su.server_id = ${authUser.serverIds[0]}`; + } else { + const serverIdList = authUser.serverIds.map((id: string) => sql`${id}`); + return sql`AND su.server_id IN (${sql.join(serverIdList, sql`, `)})`; + } + } + return sql``; + }; + + // Execute dynamic queries in parallel + const [todayPlaysResult, watchTimeResult, alertsResult, activeUsersResult] = await Promise.all([ + // Plays count + db.select({ + count: sql`count(DISTINCT COALESCE(reference_id, id))::int`, + }) + .from(sessions) + .where(and(...buildSessionConditions(todayStart))), + + // Watch time + db.select({ + totalMs: sql`COALESCE(SUM(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(and(...buildSessionConditions(todayStart))), + + // Violations count (join through serverUsers for server filtering) + db.execute(sql` + SELECT count(*)::int as count + FROM violations v + INNER JOIN server_users su ON su.id = v.server_user_id + WHERE v.created_at >= ${last24h} + ${buildViolationServerFilter()} + `).then(r => [{ count: (r.rows[0] as { count: number })?.count ?? 0 }]), + + // Unique users + db.select({ + count: sql`count(DISTINCT server_user_id)::int`, + }) + .from(sessions) + .where(and(...buildSessionConditions(todayStart))), + ]); + + todayPlays = todayPlaysResult[0]?.count ?? 0; + watchTimeHours = Math.round( + (Number(watchTimeResult[0]?.totalMs ?? 0) / (1000 * 60 * 60)) * 10 + ) / 10; + alertsLast24h = alertsResult[0]?.count ?? 0; + activeUsersToday = activeUsersResult[0]?.count ?? 0; + } + + const stats: DashboardStats = { + activeStreams, + todayPlays, + watchTimeHours, + alertsLast24h, + activeUsersToday, + }; + + // Cache for 60 seconds + await app.redis.setex(cacheKey, 60, JSON.stringify(stats)); + + return stats; + } + ); +}; diff --git a/apps/server/src/routes/stats/index.ts b/apps/server/src/routes/stats/index.ts new file mode 100644 index 0000000..a495bdf --- /dev/null +++ b/apps/server/src/routes/stats/index.ts @@ -0,0 +1,46 @@ +/** + * Statistics Routes Module + * + * Orchestrates all stats-related routes and provides unified export. + * Uses TimescaleDB continuous aggregates where possible for better performance. + * + * Routes: + * - GET /dashboard - Dashboard summary metrics + * - GET /plays - Plays over time + * - GET /plays-by-dayofweek - Plays grouped by day of week + * - GET /plays-by-hourofday - Plays grouped by hour of day + * - GET /users - User statistics + * - GET /top-users - User leaderboard + * - GET /top-content - Top movies and shows + * - GET /libraries - Library counts + * - GET /locations - Geo data for stream map + * - GET /quality - Transcode vs direct play + * - GET /platforms - Plays by platform + * - GET /watch-time - Watch time breakdown + * - GET /concurrent - Concurrent stream history + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { dashboardRoutes } from './dashboard.js'; +import { playsRoutes } from './plays.js'; +import { usersRoutes } from './users.js'; +import { contentRoutes } from './content.js'; +import { locationsRoutes } from './locations.js'; +import { qualityRoutes } from './quality.js'; + +export const statsRoutes: FastifyPluginAsync = async (app) => { + // Register all sub-route plugins + // Each plugin defines its own paths (no additional prefix needed) + await app.register(dashboardRoutes); + await app.register(playsRoutes); + await app.register(usersRoutes); + await app.register(contentRoutes); + await app.register(locationsRoutes); + await app.register(qualityRoutes); +}; + +// Re-export utilities for potential use by other modules +export { resolveDateRange, hasAggregates } from './utils.js'; +// Deprecated - kept for backwards compatibility +// eslint-disable-next-line @typescript-eslint/no-deprecated +export { getDateRange } from './utils.js'; diff --git a/apps/server/src/routes/stats/locations.ts b/apps/server/src/routes/stats/locations.ts new file mode 100644 index 0000000..2d8b159 --- /dev/null +++ b/apps/server/src/routes/stats/locations.ts @@ -0,0 +1,233 @@ +/** + * Location Statistics Routes + * + * GET /locations - Geo data for stream map with filtering + * + * Features cascading filters where each filter's available options depend on + * the other active filters. Runs 2 parallel queries per request. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { locationStatsQuerySchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { resolveDateRange } from './utils.js'; + +interface LocationFilters { + users: { id: string; username: string; identityName: string | null }[]; + servers: { id: string; name: string }[]; + mediaTypes: ('movie' | 'episode' | 'track')[]; +} + +export const locationsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /locations - Geo data for stream map with filtering + * + * Supports filtering by: + * - period: Time period (day, week, month, year, all, custom) + * - startDate/endDate: For custom period + * - serverUserId: Filter to specific user + * - serverId: Filter to specific server + * - mediaType: Filter by movie/episode/track + */ + app.get( + '/locations', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = locationStatsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverUserId, serverId, mediaType } = query.data; + const dateRange = resolveDateRange(period, startDate, endDate); + const authUser = request.user; + + // Build WHERE conditions for main query (all qualified with 's.' for sessions table) + const conditions: ReturnType[] = [ + sql`s.geo_lat IS NOT NULL`, + sql`s.geo_lon IS NOT NULL`, + ]; + + // Add date range filter (null start means "all time") + if (dateRange.start) { + conditions.push(sql`s.started_at >= ${dateRange.start}`); + } + if (period === 'custom') { + conditions.push(sql`s.started_at < ${dateRange.end}`); + } + + // Apply server access restriction if user has limited access + // Note: We build the array literal explicitly because Drizzle doesn't auto-convert JS arrays for ANY() + if (authUser.serverIds.length > 0) { + const serverIdArray = sql.raw(`ARRAY[${authUser.serverIds.map((id: string) => `'${id}'::uuid`).join(',')}]`); + conditions.push(sql`s.server_id = ANY(${serverIdArray})`); + } + + if (serverUserId) { + conditions.push(sql`s.server_user_id = ${serverUserId}`); + } + if (serverId) { + conditions.push(sql`s.server_id = ${serverId}`); + } + if (mediaType) { + conditions.push(sql`s.media_type = ${mediaType}`); + } + + const whereClause = sql`WHERE ${sql.join(conditions, sql` AND `)}`; + + // Build cascading filter conditions - each filter type sees options based on OTHER active filters + // This gives users a consistent UX where selecting one filter narrows down the others + const baseConditions: ReturnType[] = [ + sql`s.geo_lat IS NOT NULL`, + sql`s.geo_lon IS NOT NULL`, + ]; + + // Add date range filter for cascading filters + if (dateRange.start) { + baseConditions.push(sql`s.started_at >= ${dateRange.start}`); + } + if (period === 'custom') { + baseConditions.push(sql`s.started_at < ${dateRange.end}`); + } + if (authUser.serverIds.length > 0) { + const baseServerIdArray = sql.raw(`ARRAY[${authUser.serverIds.map((id: string) => `'${id}'::uuid`).join(',')}]`); + baseConditions.push(sql`s.server_id = ANY(${baseServerIdArray})`); + } + + // Users filter: apply server + mediaType filters (not user filter) + const userFilterConditions = [...baseConditions]; + if (serverId) userFilterConditions.push(sql`s.server_id = ${serverId}`); + if (mediaType) userFilterConditions.push(sql`s.media_type = ${mediaType}`); + const userFilterWhereClause = sql`WHERE ${sql.join(userFilterConditions, sql` AND `)}`; + + // Servers filter: apply user + mediaType filters (not server filter) + const serverFilterConditions = [...baseConditions]; + if (serverUserId) serverFilterConditions.push(sql`s.server_user_id = ${serverUserId}`); + if (mediaType) serverFilterConditions.push(sql`s.media_type = ${mediaType}`); + const serverFilterWhereClause = sql`WHERE ${sql.join(serverFilterConditions, sql` AND `)}`; + + // MediaType filter: apply user + server filters (not mediaType filter) + const mediaFilterConditions = [...baseConditions]; + if (serverUserId) mediaFilterConditions.push(sql`s.server_user_id = ${serverUserId}`); + if (serverId) mediaFilterConditions.push(sql`s.server_id = ${serverId}`); + const mediaFilterWhereClause = sql`WHERE ${sql.join(mediaFilterConditions, sql` AND `)}`; + + // Cascading filters are always fetched fresh (no caching since they depend on current selections) + let availableFilters: LocationFilters | null = null; + + // Execute queries in parallel (2 instead of 4 sequential) + const [mainResult, filtersResult] = await Promise.all([ + // Query 1: Main location data with all filters applied + db.execute(sql` + SELECT + s.geo_city as city, + s.geo_region as region, + s.geo_country as country, + s.geo_lat as lat, + s.geo_lon as lon, + COUNT(DISTINCT COALESCE(s.reference_id, s.id))::int as count, + MAX(s.started_at) as last_activity, + MIN(s.started_at) as first_activity, + COUNT(DISTINCT COALESCE(s.device_id, s.player_name))::int as device_count, + JSON_AGG(DISTINCT jsonb_build_object('id', su.id, 'username', su.username, 'thumbUrl', su.thumb_url)) + FILTER (WHERE su.id IS NOT NULL) as user_info + FROM sessions s + LEFT JOIN server_users su ON s.server_user_id = su.id + ${whereClause} + GROUP BY s.geo_city, s.geo_region, s.geo_country, s.geo_lat, s.geo_lon + ORDER BY count DESC + LIMIT 200 + `), + + // Query 2: Cascading filter options - each filter type uses conditions from OTHER active filters + // Note: ORDER BY not allowed within UNION subqueries, sorting done in application code + db.execute(sql` + SELECT 'user' as filter_type, su.id::text as id, su.username as name, u.name as identity_name + FROM sessions s + JOIN server_users su ON su.id = s.server_user_id + JOIN users u ON su.user_id = u.id + ${userFilterWhereClause} + GROUP BY su.id, su.username, u.name + + UNION ALL + + SELECT 'server' as filter_type, sv.id::text as id, sv.name as name, NULL as identity_name + FROM sessions s + JOIN servers sv ON sv.id = s.server_id + ${serverFilterWhereClause} + GROUP BY sv.id, sv.name + + UNION ALL + + SELECT 'media' as filter_type, s.media_type as id, s.media_type as name, NULL as identity_name + FROM sessions s + ${mediaFilterWhereClause} AND s.media_type IS NOT NULL + GROUP BY s.media_type + `), + ]); + + // Parse filter results (no caching - cascading filters depend on current selections) + // Sorting done here since ORDER BY not allowed within UNION subqueries + const filters = filtersResult.rows as { filter_type: string; id: string; name: string; identity_name: string | null }[]; + availableFilters = { + users: filters + .filter(f => f.filter_type === 'user') + .map(f => ({ id: f.id, username: f.name, identityName: f.identity_name })) + .sort((a, b) => (a.identityName ?? a.username).localeCompare(b.identityName ?? b.username)), + servers: filters + .filter(f => f.filter_type === 'server') + .map(f => ({ id: f.id, name: f.name })) + .sort((a, b) => a.name.localeCompare(b.name)), + mediaTypes: filters + .filter(f => f.filter_type === 'media') + .map(f => f.name) + .filter((t): t is 'movie' | 'episode' | 'track' => + t === 'movie' || t === 'episode' || t === 'track' + ) + .sort((a, b) => a.localeCompare(b)), + }; + + // Transform main query results + const locationStats = (mainResult.rows as { + city: string | null; + region: string | null; + country: string | null; + lat: number; + lon: number; + count: number; + last_activity: Date; + first_activity: Date; + device_count: number; + user_info: { id: string; username: string; thumbUrl: string | null }[] | null; + }[]).map((row) => ({ + city: row.city, + region: row.region, + country: row.country, + lat: row.lat, + lon: row.lon, + count: row.count, + lastActivity: row.last_activity, + firstActivity: row.first_activity, + deviceCount: row.device_count, + // Only include users array if NOT filtering by a specific user + users: serverUserId ? undefined : (row.user_info ?? []).slice(0, 5), + })); + + // Calculate summary stats for the overlay card + const totalStreams = locationStats.reduce((sum, loc) => sum + loc.count, 0); + const uniqueLocations = locationStats.length; + const topCity = locationStats[0]?.city ?? null; + + return { + data: locationStats, + summary: { + totalStreams, + uniqueLocations, + topCity, + }, + availableFilters: availableFilters ?? { users: [], servers: [], mediaTypes: [] }, + }; + } + ); +}; diff --git a/apps/server/src/routes/stats/plays.ts b/apps/server/src/routes/stats/plays.ts new file mode 100644 index 0000000..60d2e60 --- /dev/null +++ b/apps/server/src/routes/stats/plays.ts @@ -0,0 +1,200 @@ +/** + * Play Statistics Routes + * + * GET /plays - Plays over time + * GET /plays-by-dayofweek - Plays grouped by day of week + * GET /plays-by-hourofday - Plays grouped by hour of day + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { statsQuerySchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { resolveDateRange } from './utils.js'; +import { validateServerAccess } from '../../utils/serverFiltering.js'; + +/** + * Build SQL server filter fragment for raw queries + */ +function buildServerFilterSql( + serverId: string | undefined, + authUser: { role: string; serverIds: string[] } +): ReturnType { + if (serverId) { + return sql`AND server_id = ${serverId}`; + } + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + return sql`AND false`; + } else if (authUser.serverIds.length === 1) { + return sql`AND server_id = ${authUser.serverIds[0]}`; + } else { + const serverIdList = authUser.serverIds.map(id => sql`${id}`); + return sql`AND server_id IN (${sql.join(serverIdList, sql`, `)})`; + } + } + return sql``; +} + +export const playsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /plays - Plays over time + */ + app.get( + '/plays', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + + const result = await db.execute(sql` + SELECT + date_trunc('day', started_at)::date::text as date, + count(DISTINCT COALESCE(reference_id, id))::int as count + FROM sessions + ${baseWhere} + ${period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``} + ${serverFilter} + GROUP BY date_trunc('day', started_at) + ORDER BY date_trunc('day', started_at) + `); + + return { data: result.rows as { date: string; count: number }[] }; + } + ); + + /** + * GET /plays-by-dayofweek - Plays grouped by day of week + */ + app.get( + '/plays-by-dayofweek', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + + const result = await db.execute(sql` + SELECT + EXTRACT(DOW FROM started_at)::int as day, + COUNT(DISTINCT COALESCE(reference_id, id))::int as count + FROM sessions + ${baseWhere} + ${period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``} + ${serverFilter} + GROUP BY EXTRACT(DOW FROM started_at) + ORDER BY day + `); + + const dayStats = result.rows as { day: number; count: number }[]; + + // Ensure all 7 days are present (fill missing with 0) + const dayMap = new Map(dayStats.map((d) => [d.day, d.count])); + const data = Array.from({ length: 7 }, (_, i) => ({ + day: i, + name: DAY_NAMES[i], + count: dayMap.get(i) ?? 0, + })); + + return { data }; + } + ); + + /** + * GET /plays-by-hourofday - Plays grouped by hour of day + */ + app.get( + '/plays-by-hourofday', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + + const result = await db.execute(sql` + SELECT + EXTRACT(HOUR FROM started_at)::int as hour, + COUNT(DISTINCT COALESCE(reference_id, id))::int as count + FROM sessions + ${baseWhere} + ${period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``} + ${serverFilter} + GROUP BY EXTRACT(HOUR FROM started_at) + ORDER BY hour + `); + + const hourStats = result.rows as { hour: number; count: number }[]; + + // Ensure all 24 hours are present (fill missing with 0) + const hourMap = new Map(hourStats.map((h) => [h.hour, h.count])); + const data = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + count: hourMap.get(i) ?? 0, + })); + + return { data }; + } + ); +}; diff --git a/apps/server/src/routes/stats/quality.ts b/apps/server/src/routes/stats/quality.ts new file mode 100644 index 0000000..ec00726 --- /dev/null +++ b/apps/server/src/routes/stats/quality.ts @@ -0,0 +1,331 @@ +/** + * Quality and Performance Statistics Routes + * + * GET /quality - Transcode vs direct play breakdown + * GET /platforms - Plays by platform + * GET /watch-time - Total watch time breakdown + * GET /concurrent - Concurrent stream history + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { statsQuerySchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import '../../db/schema.js'; +import { resolveDateRange } from './utils.js'; +import { validateServerAccess } from '../../utils/serverFiltering.js'; + +/** + * Build SQL server filter fragment for raw queries + */ +function buildServerFilterSql( + serverId: string | undefined, + authUser: { role: string; serverIds: string[] } +): ReturnType { + if (serverId) { + return sql`AND server_id = ${serverId}`; + } + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + return sql`AND false`; + } else if (authUser.serverIds.length === 1) { + return sql`AND server_id = ${authUser.serverIds[0]}`; + } else { + const serverIdList = authUser.serverIds.map(id => sql`${id}`); + return sql`AND server_id IN (${sql.join(serverIdList, sql`, `)})`; + } + } + return sql``; +} + +export const qualityRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /quality - Transcode vs direct play breakdown + */ + app.get( + '/quality', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + + const result = await db.execute(sql` + SELECT + is_transcode, + COUNT(DISTINCT COALESCE(reference_id, id))::int as count + FROM sessions + ${baseWhere} + ${period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``} + ${serverFilter} + GROUP BY is_transcode + `); + + const qualityStats = result.rows as { is_transcode: boolean | null; count: number }[]; + + const directPlay = qualityStats.find((q) => !q.is_transcode)?.count ?? 0; + const transcode = qualityStats.find((q) => q.is_transcode)?.count ?? 0; + const total = directPlay + transcode; + + return { + directPlay, + transcode, + total, + directPlayPercent: total > 0 ? Math.round((directPlay / total) * 100) : 0, + transcodePercent: total > 0 ? Math.round((transcode / total) * 100) : 0, + }; + } + ); + + /** + * GET /platforms - Plays by platform + */ + app.get( + '/platforms', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + + const result = await db.execute(sql` + SELECT + platform, + COUNT(DISTINCT COALESCE(reference_id, id))::int as count + FROM sessions + ${baseWhere} + ${period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``} + ${serverFilter} + GROUP BY platform + ORDER BY count DESC + `); + + return { data: result.rows as { platform: string | null; count: number }[] }; + } + ); + + /** + * GET /watch-time - Total watch time breakdown + */ + app.get( + '/watch-time', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time queries, we need a base WHERE clause + const baseWhere = dateRange.start + ? sql`WHERE started_at >= ${dateRange.start}` + : sql`WHERE true`; + const customEndFilter = period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``; + + const [totalResult, byTypeResult] = await Promise.all([ + db.execute(sql` + SELECT COALESCE(SUM(duration_ms), 0)::bigint as total_ms + FROM sessions + ${baseWhere} + ${customEndFilter} + ${serverFilter} + `), + db.execute(sql` + SELECT + media_type, + COALESCE(SUM(duration_ms), 0)::bigint as total_ms + FROM sessions + ${baseWhere} + ${customEndFilter} + ${serverFilter} + GROUP BY media_type + `), + ]); + + const totalMs = (totalResult.rows[0] as { total_ms: string })?.total_ms ?? '0'; + const byType = (byTypeResult.rows as { media_type: string | null; total_ms: string }[]); + + return { + totalHours: Math.round((Number(totalMs) / (1000 * 60 * 60)) * 10) / 10, + byType: byType.map((t) => ({ + mediaType: t.media_type, + hours: Math.round((Number(t.total_ms) / (1000 * 60 * 60)) * 10) / 10, + })), + }; + } + ); + + /** + * GET /concurrent - Peak concurrent streams per hour with direct/transcode breakdown + * + * Calculates TRUE peak concurrent: the maximum number of sessions running + * simultaneously at any moment within each hour. + * + * Algorithm: + * 1. Create "initial state" events for sessions already running at startDate + * 2. Create events for session starts (+1) and stops (-1) within the window + * 3. Use window function to calculate running count at each event + * 4. Group by hour and take MAX for peak concurrent + */ + app.get( + '/concurrent', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // For all-time, we use epoch as start; otherwise use the resolved date + const queryStartDate = dateRange.start ?? new Date(0); + const customEndFilter = period === 'custom' ? sql`AND started_at < ${dateRange.end}` : sql``; + const customStopEndFilter = period === 'custom' ? sql`AND stopped_at < ${dateRange.end}` : sql``; + + // Event-based calculation with proper boundary handling + // Uses TimescaleDB-optimized time-based filtering on hypertable + const result = await db.execute(sql` + WITH events AS ( + -- Sessions already running at startDate (started before, not yet stopped) + -- These need a +1 event at startDate to establish initial state + SELECT + ${queryStartDate}::timestamp AS event_time, + 1 AS delta, + CASE WHEN is_transcode THEN 0 ELSE 1 END AS direct_delta, + CASE WHEN is_transcode THEN 1 ELSE 0 END AS transcode_delta + FROM sessions + WHERE started_at < ${queryStartDate} + AND (stopped_at IS NULL OR stopped_at >= ${queryStartDate}) + ${serverFilter} + + UNION ALL + + -- Session start events within the window + SELECT + started_at AS event_time, + 1 AS delta, + CASE WHEN is_transcode THEN 0 ELSE 1 END AS direct_delta, + CASE WHEN is_transcode THEN 1 ELSE 0 END AS transcode_delta + FROM sessions + WHERE started_at >= ${queryStartDate} + ${customEndFilter} + ${serverFilter} + + UNION ALL + + -- Session stop events within the window + SELECT + stopped_at AS event_time, + -1 AS delta, + CASE WHEN is_transcode THEN 0 ELSE -1 END AS direct_delta, + CASE WHEN is_transcode THEN -1 ELSE 0 END AS transcode_delta + FROM sessions + WHERE stopped_at IS NOT NULL + AND stopped_at >= ${queryStartDate} + ${customStopEndFilter} + ${serverFilter} + ), + running_counts AS ( + -- Running sum gives concurrent count at each event point + SELECT + event_time, + SUM(delta) OVER w AS concurrent, + SUM(direct_delta) OVER w AS direct_concurrent, + SUM(transcode_delta) OVER w AS transcode_concurrent + FROM events + WHERE event_time IS NOT NULL + WINDOW w AS (ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) + ) + SELECT + date_trunc('hour', event_time)::text AS hour, + COALESCE(MAX(concurrent), 0)::int AS total, + COALESCE(MAX(direct_concurrent), 0)::int AS direct, + COALESCE(MAX(transcode_concurrent), 0)::int AS transcode + FROM running_counts + GROUP BY date_trunc('hour', event_time) + ORDER BY hour + `); + + const hourlyData = (result.rows as { + hour: string; + total: number; + direct: number; + transcode: number; + }[]).map((r) => ({ + hour: r.hour, + total: r.total, + direct: r.direct, + transcode: r.transcode, + })); + + return { data: hourlyData }; + } + ); +}; diff --git a/apps/server/src/routes/stats/users.ts b/apps/server/src/routes/stats/users.ts new file mode 100644 index 0000000..0292357 --- /dev/null +++ b/apps/server/src/routes/stats/users.ts @@ -0,0 +1,196 @@ +/** + * User Statistics Routes + * + * GET /users - User statistics with play counts (per ServerUser) + * GET /top-users - User leaderboard by watch time (per ServerUser) + * + * Stats are per-ServerUser (server account), not per-User (identity). + * Each server account is tracked separately. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { sql } from 'drizzle-orm'; +import { statsQuerySchema } from '@tracearr/shared'; +import type { UserStats, TopUserStats } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { resolveDateRange } from './utils.js'; +import { validateServerAccess } from '../../utils/serverFiltering.js'; + +/** + * Build SQL server filter fragment for raw queries + */ +function buildServerFilterSql( + serverId: string | undefined, + authUser: { role: string; serverIds: string[] } +): ReturnType { + if (serverId) { + return sql`AND su.server_id = ${serverId}`; + } + if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 0) { + return sql`AND false`; + } else if (authUser.serverIds.length === 1) { + return sql`AND su.server_id = ${authUser.serverIds[0]}`; + } else { + const serverIdList = authUser.serverIds.map(id => sql`${id}`); + return sql`AND su.server_id IN (${sql.join(serverIdList, sql`, `)})`; + } + } + return sql``; +} + +export const usersRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /users - User statistics (per ServerUser) + */ + app.get( + '/users', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // Build date filter for JOIN condition + const dateJoinFilter = dateRange.start + ? period === 'custom' + ? sql`AND s.started_at >= ${dateRange.start} AND s.started_at < ${dateRange.end}` + : sql`AND s.started_at >= ${dateRange.start}` + : sql``; // All-time: no date filter + + // Query server_users with session stats + // Stats are per-server-account (ServerUser), not per-identity (User) + const result = await db.execute(sql` + SELECT + su.id as server_user_id, + su.username, + su.thumb_url, + COUNT(DISTINCT COALESCE(s.reference_id, s.id))::int as play_count, + COALESCE(SUM(s.duration_ms), 0)::bigint as watch_time_ms + FROM server_users su + LEFT JOIN sessions s ON s.server_user_id = su.id ${dateJoinFilter} + WHERE true ${serverFilter} + GROUP BY su.id, su.username, su.thumb_url + ORDER BY play_count DESC + LIMIT 20 + `); + + const userStats: UserStats[] = (result.rows as { + server_user_id: string; + username: string; + thumb_url: string | null; + play_count: number; + watch_time_ms: string; + }[]).map((r) => ({ + serverUserId: r.server_user_id, + username: r.username, + thumbUrl: r.thumb_url, + playCount: r.play_count, + watchTimeHours: Math.round((Number(r.watch_time_ms) / (1000 * 60 * 60)) * 10) / 10, + })); + + return { data: userStats }; + } + ); + + /** + * GET /top-users - User leaderboard (per ServerUser) + */ + app.get( + '/top-users', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = statsQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { period, startDate, endDate, serverId } = query.data; + const authUser = request.user; + const dateRange = resolveDateRange(period, startDate, endDate); + + // Validate server access if specific server requested + if (serverId) { + const error = validateServerAccess(authUser, serverId); + if (error) { + return reply.forbidden(error); + } + } + + const serverFilter = buildServerFilterSql(serverId, authUser); + + // Build date filter for JOIN condition + const dateJoinFilter = dateRange.start + ? period === 'custom' + ? sql`AND s.started_at >= ${dateRange.start} AND s.started_at < ${dateRange.end}` + : sql`AND s.started_at >= ${dateRange.start}` + : sql``; // All-time: no date filter + + // Query server_users with session stats + // Stats are per-server-account (ServerUser), not per-identity (User) + // Include server_id for avatar proxy and top genre/show + // Join with users table to get identity name + const topUsersResult = await db.execute(sql` + SELECT + su.id as server_user_id, + su.username, + u.name as identity_name, + su.thumb_url, + su.server_id::text, + su.trust_score, + COUNT(DISTINCT COALESCE(s.reference_id, s.id))::int as play_count, + COALESCE(SUM(s.duration_ms), 0)::bigint as watch_time_ms, + MODE() WITHIN GROUP (ORDER BY s.media_type) as top_media_type, + MODE() WITHIN GROUP (ORDER BY COALESCE(s.grandparent_title, s.media_title)) as top_content + FROM server_users su + INNER JOIN users u ON su.user_id = u.id + LEFT JOIN sessions s ON s.server_user_id = su.id ${dateJoinFilter} + WHERE true ${serverFilter} + GROUP BY su.id, su.username, u.name, su.thumb_url, su.server_id, su.trust_score + ORDER BY watch_time_ms DESC + LIMIT 10 + `); + + const topUsers: TopUserStats[] = (topUsersResult.rows as { + server_user_id: string; + username: string; + identity_name: string | null; + thumb_url: string | null; + server_id: string | null; + trust_score: number; + play_count: number; + watch_time_ms: string; + top_media_type: string | null; + top_content: string | null; + }[]).map((r) => ({ + serverUserId: r.server_user_id, + username: r.username, + identityName: r.identity_name, + thumbUrl: r.thumb_url, + serverId: r.server_id, + trustScore: r.trust_score, + playCount: r.play_count, + watchTimeHours: Math.round((Number(r.watch_time_ms) / (1000 * 60 * 60)) * 10) / 10, + topMediaType: r.top_media_type, + topContent: r.top_content, + })); + + return { data: topUsers }; + } + ); +}; diff --git a/apps/server/src/routes/stats/utils.ts b/apps/server/src/routes/stats/utils.ts new file mode 100644 index 0000000..d42fea4 --- /dev/null +++ b/apps/server/src/routes/stats/utils.ts @@ -0,0 +1,172 @@ +/** + * Stats Route Utilities + * + * Shared helpers for statistics routes including date range calculation + * and TimescaleDB aggregate availability checking. + */ + +import { TIME_MS } from '@tracearr/shared'; +import { sql, type SQL } from 'drizzle-orm'; +import { db } from '../../db/client.js'; +import { getTimescaleStatus } from '../../db/timescale.js'; + +// Cache whether aggregates are available (checked once at startup) +let aggregatesAvailable: boolean | null = null; +let hyperLogLogAvailable: boolean | null = null; + +/** + * Check if TimescaleDB continuous aggregates are available. + * Result is cached after first check. + */ +export async function hasAggregates(): Promise { + if (aggregatesAvailable !== null) { + return aggregatesAvailable; + } + try { + const status = await getTimescaleStatus(); + aggregatesAvailable = status.continuousAggregates.length >= 3; + return aggregatesAvailable; + } catch { + aggregatesAvailable = false; + return false; + } +} + +/** + * Check if TimescaleDB Toolkit (HyperLogLog) is available AND the aggregates + * have HLL columns. This is important because: + * 1. Extension might be installed but aggregates created without HLL + * 2. Aggregates might exist but without HLL columns if toolkit wasn't available at migration time + * + * Result is cached after first check. + */ +export async function hasHyperLogLog(): Promise { + if (hyperLogLogAvailable !== null) { + return hyperLogLogAvailable; + } + try { + // Check both: extension installed AND aggregate has plays_hll column + const result = await db.execute(sql` + SELECT + EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'timescaledb_toolkit') as extension_installed, + EXISTS( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'daily_stats_summary' + AND column_name = 'plays_hll' + ) as hll_column_exists + `); + const row = result.rows[0] as { extension_installed: boolean; hll_column_exists: boolean } | undefined; + hyperLogLogAvailable = (row?.extension_installed && row?.hll_column_exists) ?? false; + return hyperLogLogAvailable; + } catch { + hyperLogLogAvailable = false; + return false; + } +} + +/** + * Reset cached state (useful for testing) + */ +export function resetCachedState(): void { + aggregatesAvailable = null; + hyperLogLogAvailable = null; +} + +/** + * Calculate start date based on period string. + * + * @param period - Time period: 'day', 'week', 'month', or 'year' + * @returns Date representing the start of the period + * @deprecated Use resolveDateRange() instead for new code + */ +export function getDateRange(period: 'day' | 'week' | 'month' | 'year'): Date { + const now = new Date(); + switch (period) { + case 'day': + return new Date(now.getTime() - TIME_MS.DAY); + case 'week': + return new Date(now.getTime() - TIME_MS.WEEK); + case 'month': + return new Date(now.getTime() - 30 * TIME_MS.DAY); + case 'year': + return new Date(now.getTime() - 365 * TIME_MS.DAY); + } +} + +// ============================================================================ +// New Date Range API (supports 'all' and 'custom' periods) +// ============================================================================ + +export type StatsPeriod = 'day' | 'week' | 'month' | 'year' | 'all' | 'custom'; + +export interface DateRange { + /** Start date, or null for "all time" (no lower bound) */ + start: Date | null; + /** End date (typically "now") */ + end: Date; +} + +/** + * Resolves period/custom dates into a concrete date range. + * All queries use raw sessions table (no aggregates needed at current data volume). + * + * @param period - The period type + * @param startDate - Custom start date (ISO string), required when period='custom' + * @param endDate - Custom end date (ISO string), required when period='custom' + * @returns DateRange with start (null for all-time) and end dates + */ +export function resolveDateRange( + period: StatsPeriod, + startDate?: string, + endDate?: string +): DateRange { + const now = new Date(); + + switch (period) { + case 'day': + return { start: new Date(now.getTime() - TIME_MS.DAY), end: now }; + case 'week': + return { start: new Date(now.getTime() - TIME_MS.WEEK), end: now }; + case 'month': + return { start: new Date(now.getTime() - 30 * TIME_MS.DAY), end: now }; + case 'year': + return { start: new Date(now.getTime() - 365 * TIME_MS.DAY), end: now }; + case 'all': + return { start: null, end: now }; + case 'custom': + if (!startDate || !endDate) { + throw new Error('Custom period requires startDate and endDate'); + } + return { + start: new Date(startDate), + end: new Date(endDate), + }; + } +} + +/** + * Builds SQL WHERE clause fragment for date range filtering. + * + * For preset periods (day, week, month, year): WHERE started_at >= ${start} + * For all-time (start is null): Returns empty SQL (no time filter) + * For custom range: WHERE started_at >= ${start} AND started_at < ${end} + * + * @param range - DateRange from resolveDateRange() + * @param includeEndBound - Whether to include upper bound (for custom ranges) + * @returns SQL fragment to append to WHERE clause (includes leading AND) + */ +export function buildDateRangeFilter(range: DateRange, includeEndBound = false): SQL { + if (!range.start) { + // All-time: no time filter + return sql``; + } + + if (includeEndBound) { + // Custom range: filter both bounds + return sql` AND started_at >= ${range.start} AND started_at < ${range.end}`; + } + + // Preset period: only lower bound (end is always "now") + return sql` AND started_at >= ${range.start}`; +} diff --git a/apps/server/src/routes/users/__tests__/terminations.test.ts b/apps/server/src/routes/users/__tests__/terminations.test.ts new file mode 100644 index 0000000..53b6d69 --- /dev/null +++ b/apps/server/src/routes/users/__tests__/terminations.test.ts @@ -0,0 +1,504 @@ +/** + * User Terminations routes tests + * + * Tests the API endpoint for user termination history: + * - GET /:id/terminations - Get termination history for a user + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; +import { randomUUID } from 'node:crypto'; +import type { AuthUser, TerminationTrigger, MediaType } from '@tracearr/shared'; + +// Mock the database module before importing routes +vi.mock('../../../db/client.js', () => ({ + db: { + select: vi.fn(), + }, +})); + +// Import the mocked db and the routes +import { db } from '../../../db/client.js'; +import { terminationsRoutes } from '../terminations.js'; + +/** + * Build a test Fastify instance with mocked auth + */ +async function buildTestApp(authUser: AuthUser): Promise { + const app = Fastify({ logger: false }); + + // Register sensible for HTTP error helpers + await app.register(sensible); + + // Mock the authenticate decorator + app.decorate('authenticate', async (request: any) => { + request.user = authUser; + }); + + // Register routes under /users prefix (matching real app structure) + await app.register(terminationsRoutes, { prefix: '/users' }); + + return app; +} + +/** + * Create a mock termination log with joined data + */ +interface MockTerminationLog { + id: string; + sessionId: string; + serverId: string; + serverUserId: string; + trigger: TerminationTrigger; + triggeredByUserId: string | null; + triggeredByUsername: string | null; + ruleId: string | null; + ruleName: string | null; + violationId: string | null; + reason: string | null; + success: boolean; + errorMessage: string | null; + createdAt: Date; + mediaTitle: string | null; + mediaType: MediaType | null; +} + +function createTestTermination( + overrides: Partial = {} +): MockTerminationLog { + return { + id: overrides.id ?? randomUUID(), + sessionId: overrides.sessionId ?? randomUUID(), + serverId: overrides.serverId ?? randomUUID(), + serverUserId: overrides.serverUserId ?? randomUUID(), + trigger: overrides.trigger ?? 'manual', + // Use 'in' check to allow explicit null values + triggeredByUserId: 'triggeredByUserId' in overrides ? overrides.triggeredByUserId! : randomUUID(), + triggeredByUsername: 'triggeredByUsername' in overrides ? overrides.triggeredByUsername! : 'admin', + ruleId: overrides.ruleId ?? null, + ruleName: overrides.ruleName ?? null, + violationId: overrides.violationId ?? null, + reason: overrides.reason ?? 'Test termination', + success: overrides.success ?? true, + errorMessage: overrides.errorMessage ?? null, + createdAt: overrides.createdAt ?? new Date(), + mediaTitle: overrides.mediaTitle ?? 'Test Movie', + mediaType: overrides.mediaType ?? 'movie', + }; +} + +/** + * Create a mock server user + */ +interface MockServerUser { + id: string; + serverId: string; + username: string; +} + +function createTestServerUser(overrides: Partial = {}): MockServerUser { + return { + id: overrides.id ?? randomUUID(), + serverId: overrides.serverId ?? randomUUID(), + username: overrides.username ?? 'testuser', + }; +} + +/** + * Create a mock owner auth user + */ +function createOwnerUser(serverIds: string[] = [randomUUID()]): AuthUser { + return { + userId: randomUUID(), + username: 'owner', + role: 'owner', + serverIds, + }; +} + +/** + * Create a mock viewer auth user + */ +function createViewerUser(serverIds: string[] = [randomUUID()]): AuthUser { + return { + userId: randomUUID(), + username: 'viewer', + role: 'viewer', + serverIds, + }; +} + +/** + * Helper to create mock chain for server user lookup + */ +function createServerUserSelectMock(resolvedValue: unknown) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(resolvedValue), + }), + }), + }; +} + +/** + * Helper to create mock chain for terminations query (3 leftJoins) + */ +function createTerminationsSelectMock(resolvedValue: unknown) { + return { + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + offset: vi.fn().mockResolvedValue(resolvedValue), + }), + }), + }), + }), + }), + }), + }), + }; +} + +/** + * Helper to create mock chain for count query + */ +function createCountSelectMock(count: number) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count }]), + }), + }; +} + +describe('User Terminations Routes', () => { + let app: FastifyInstance; + let mockDb: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockDb = db as any; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('GET /users/:id/terminations', () => { + it('should return termination history for a user', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const terminations = [ + createTestTermination({ serverUserId, serverId, trigger: 'manual' }), + createTestTermination({ serverUserId, serverId, trigger: 'rule', ruleName: 'Max Streams' }), + ]; + + // Mock server user lookup + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + // Mock terminations query + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock(terminations)); + // Mock count query + mockDb.select.mockReturnValueOnce(createCountSelectMock(2)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(2); + expect(body.total).toBe(2); + expect(body.page).toBe(1); + expect(body.pageSize).toBe(20); + expect(body.totalPages).toBe(1); + }); + + it('should apply pagination parameters', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const terminations = [createTestTermination({ serverUserId, serverId })]; + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock(terminations)); + mockDb.select.mockReturnValueOnce(createCountSelectMock(50)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations?page=2&pageSize=10`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.page).toBe(2); + expect(body.pageSize).toBe(10); + expect(body.total).toBe(50); + expect(body.totalPages).toBe(5); + }); + + it('should return 404 for non-existent user', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + // Mock server user lookup returning empty + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([])); + + const response = await app.inject({ + method: 'GET', + url: `/users/${randomUUID()}/terminations`, + }); + + expect(response.statusCode).toBe(404); + const body = JSON.parse(response.body); + expect(body.message).toBe('User not found'); + }); + + it('should return 403 when user lacks server access', async () => { + const userServerId = randomUUID(); + const differentServerId = randomUUID(); + const serverUserId = randomUUID(); + + // User has access to userServerId but serverUser belongs to differentServerId + const viewerUser = createViewerUser([userServerId]); + app = await buildTestApp(viewerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId: differentServerId }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.body); + expect(body.message).toBe('You do not have access to this user'); + }); + + it('should return 400 for invalid user ID', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: '/users/not-a-uuid/terminations', + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.message).toBe('Invalid user ID'); + }); + + it('should return 400 for invalid pagination parameters', async () => { + const ownerUser = createOwnerUser(); + app = await buildTestApp(ownerUser); + + const response = await app.inject({ + method: 'GET', + url: `/users/${randomUUID()}/terminations?page=-1`, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.message).toBe('Invalid query parameters'); + }); + + it('should return empty data when no terminations exist', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock([])); + mockDb.select.mockReturnValueOnce(createCountSelectMock(0)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(0); + expect(body.total).toBe(0); + expect(body.totalPages).toBe(0); + }); + + it('should include manual termination details', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const triggeredByUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const termination = createTestTermination({ + serverUserId, + serverId, + trigger: 'manual', + triggeredByUserId, + triggeredByUsername: 'admin_user', + reason: 'User was streaming inappropriate content', + ruleId: null, + ruleName: null, + }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock([termination])); + mockDb.select.mockReturnValueOnce(createCountSelectMock(1)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data[0].trigger).toBe('manual'); + expect(body.data[0].triggeredByUsername).toBe('admin_user'); + expect(body.data[0].reason).toBe('User was streaming inappropriate content'); + expect(body.data[0].ruleId).toBeNull(); + expect(body.data[0].ruleName).toBeNull(); + }); + + it('should include rule-triggered termination details', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ruleId = randomUUID(); + const violationId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const termination = createTestTermination({ + serverUserId, + serverId, + trigger: 'rule', + triggeredByUserId: null, + triggeredByUsername: null, + ruleId, + ruleName: 'Concurrent Streams Limit', + violationId, + reason: 'Exceeded maximum concurrent streams (3/2)', + }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock([termination])); + mockDb.select.mockReturnValueOnce(createCountSelectMock(1)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data[0].trigger).toBe('rule'); + expect(body.data[0].ruleId).toBe(ruleId); + expect(body.data[0].ruleName).toBe('Concurrent Streams Limit'); + expect(body.data[0].violationId).toBe(violationId); + expect(body.data[0].triggeredByUserId).toBeNull(); + expect(body.data[0].triggeredByUsername).toBeNull(); + }); + + it('should include session media information', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const termination = createTestTermination({ + serverUserId, + serverId, + mediaTitle: 'Breaking Bad S01E01', + mediaType: 'episode', + }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock([termination])); + mockDb.select.mockReturnValueOnce(createCountSelectMock(1)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data[0].mediaTitle).toBe('Breaking Bad S01E01'); + expect(body.data[0].mediaType).toBe('episode'); + }); + + it('should include failed termination details', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const ownerUser = createOwnerUser([serverId]); + app = await buildTestApp(ownerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const termination = createTestTermination({ + serverUserId, + serverId, + success: false, + errorMessage: 'Session already ended', + }); + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock([termination])); + mockDb.select.mockReturnValueOnce(createCountSelectMock(1)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data[0].success).toBe(false); + expect(body.data[0].errorMessage).toBe('Session already ended'); + }); + + it('should allow viewer with server access to see terminations', async () => { + const serverId = randomUUID(); + const serverUserId = randomUUID(); + const viewerUser = createViewerUser([serverId]); + app = await buildTestApp(viewerUser); + + const serverUser = createTestServerUser({ id: serverUserId, serverId }); + const terminations = [createTestTermination({ serverUserId, serverId })]; + + mockDb.select.mockReturnValueOnce(createServerUserSelectMock([serverUser])); + mockDb.select.mockReturnValueOnce(createTerminationsSelectMock(terminations)); + mockDb.select.mockReturnValueOnce(createCountSelectMock(1)); + + const response = await app.inject({ + method: 'GET', + url: `/users/${serverUserId}/terminations`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + }); + }); +}); diff --git a/apps/server/src/routes/users/devices.ts b/apps/server/src/routes/users/devices.ts new file mode 100644 index 0000000..ff57221 --- /dev/null +++ b/apps/server/src/routes/users/devices.ts @@ -0,0 +1,166 @@ +/** + * User Devices Route + * + * GET /:id/devices - Get user's unique devices (aggregated from sessions) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc } from 'drizzle-orm'; +import { userIdParamSchema, type UserDevice } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { serverUsers, sessions } from '../../db/schema.js'; + +export const devicesRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /:id/devices - Get user's unique devices (aggregated from sessions) + */ + app.get( + '/:id/devices', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Verify server user exists and access + const serverUserRows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + if (!authUser.serverIds.includes(serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Aggregate devices from sessions with location data + // Use deviceId as primary key (fallback to playerName if deviceId is null) + // Aggregate locations where each device has been used + const sessionData = await db + .select({ + deviceId: sessions.deviceId, + playerName: sessions.playerName, + product: sessions.product, + device: sessions.device, + platform: sessions.platform, + startedAt: sessions.startedAt, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)) + .orderBy(desc(sessions.startedAt)); + + // Group by deviceId (or playerName as fallback) + const deviceMap = new Map; + }>(); + + for (const session of sessionData) { + // Use deviceId as key, or playerName as fallback, or generate a hash from metadata + const key = session.deviceId + ?? session.playerName + ?? `${session.product ?? 'unknown'}-${session.device ?? 'unknown'}-${session.platform ?? 'unknown'}`; + + const existing = deviceMap.get(key); + if (existing) { + existing.sessionCount++; + // Update lastSeenAt if this session is more recent + if (session.startedAt > existing.lastSeenAt) { + existing.lastSeenAt = session.startedAt; + // Use most recent values for metadata + existing.playerName = session.playerName ?? existing.playerName; + existing.product = session.product ?? existing.product; + existing.device = session.device ?? existing.device; + existing.platform = session.platform ?? existing.platform; + } + + // Aggregate location + const locKey = `${session.geoCity ?? ''}-${session.geoRegion ?? ''}-${session.geoCountry ?? ''}`; + const existingLoc = existing.locationMap.get(locKey); + if (existingLoc) { + existingLoc.sessionCount++; + if (session.startedAt > existingLoc.lastSeenAt) { + existingLoc.lastSeenAt = session.startedAt; + } + } else { + existing.locationMap.set(locKey, { + city: session.geoCity, + region: session.geoRegion, + country: session.geoCountry, + sessionCount: 1, + lastSeenAt: session.startedAt, + }); + } + } else { + const locationMap = new Map(); + const locKey = `${session.geoCity ?? ''}-${session.geoRegion ?? ''}-${session.geoCountry ?? ''}`; + locationMap.set(locKey, { + city: session.geoCity, + region: session.geoRegion, + country: session.geoCountry, + sessionCount: 1, + lastSeenAt: session.startedAt, + }); + + deviceMap.set(key, { + deviceId: session.deviceId, + playerName: session.playerName, + product: session.product, + device: session.device, + platform: session.platform, + sessionCount: 1, + lastSeenAt: session.startedAt, + locationMap, + }); + } + } + + // Convert to array and sort by last seen + const devices: UserDevice[] = Array.from(deviceMap.values()) + .map((dev) => ({ + deviceId: dev.deviceId, + playerName: dev.playerName, + product: dev.product, + device: dev.device, + platform: dev.platform, + sessionCount: dev.sessionCount, + lastSeenAt: dev.lastSeenAt, + locations: Array.from(dev.locationMap.values()) + .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime()), + })) + .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime()); + + return { data: devices }; + } + ); +}; diff --git a/apps/server/src/routes/users/full.ts b/apps/server/src/routes/users/full.ts new file mode 100644 index 0000000..ff2295f --- /dev/null +++ b/apps/server/src/routes/users/full.ts @@ -0,0 +1,430 @@ +/** + * User Full Detail Route (Aggregate Endpoint) + * + * GET /:id/full - Get complete user details with all related data in one request + * + * This endpoint combines: + * - User details + * - Session stats and recent sessions + * - Locations + * - Devices + * - Violations + * - Termination history + * + * Purpose: Reduce frontend from 6 API calls to 1, eliminating waterfall requests + * and reducing TimescaleDB query planning overhead. + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc, sql } from 'drizzle-orm'; +import { userIdParamSchema, type UserLocation, type UserDevice } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { + serverUsers, + sessions, + servers, + users, + violations, + rules, + terminationLogs, +} from '../../db/schema.js'; +import { hasServerAccess } from '../../utils/serverFiltering.js'; + +export const fullRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /:id/full - Get complete user details in one request + * + * Returns user info + stats + recent sessions + locations + devices + violations + terminations + * All in a single database transaction for consistency. + */ + app.get( + '/:id/full', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Limits for embedded data (not paginated, just initial load) + const sessionsLimit = 10; + const violationsLimit = 10; + const terminationsLimit = 10; + + // Use a transaction for consistent reads + const result = await db.transaction(async (tx) => { + // 1. Get user details with server info + const serverUserRows = await tx + .select({ + id: serverUsers.id, + serverId: serverUsers.serverId, + serverName: servers.name, + userId: serverUsers.userId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + identityName: users.name, + role: users.role, + }) + .from(serverUsers) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return { error: 'notFound' as const }; + } + + // Verify access + if (!hasServerAccess(authUser, serverUser.serverId)) { + return { error: 'forbidden' as const }; + } + + // 2. Get session stats (single query) + const statsResult = await tx + .select({ + totalSessions: sql`count(*)::int`, + totalWatchTime: sql`coalesce(sum(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)); + + const stats = statsResult[0]; + + // 3. Get recent sessions (paginated first page) + const recentSessions = await tx + .select({ + id: sessions.id, + serverId: sessions.serverId, + serverName: servers.name, + serverUserId: sessions.serverUserId, + sessionKey: sessions.sessionKey, + state: sessions.state, + mediaType: sessions.mediaType, + mediaTitle: sessions.mediaTitle, + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + thumbPath: sessions.thumbPath, + ratingKey: sessions.ratingKey, + externalSessionId: sessions.externalSessionId, + startedAt: sessions.startedAt, + stoppedAt: sessions.stoppedAt, + durationMs: sessions.durationMs, + totalDurationMs: sessions.totalDurationMs, + progressMs: sessions.progressMs, + lastPausedAt: sessions.lastPausedAt, + pausedDurationMs: sessions.pausedDurationMs, + referenceId: sessions.referenceId, + watched: sessions.watched, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + deviceId: sessions.deviceId, + product: sessions.product, + device: sessions.device, + platform: sessions.platform, + quality: sessions.quality, + isTranscode: sessions.isTranscode, + bitrate: sessions.bitrate, + }) + .from(sessions) + .innerJoin(servers, eq(sessions.serverId, servers.id)) + .where(eq(sessions.serverUserId, id)) + .orderBy(desc(sessions.startedAt)) + .limit(sessionsLimit); + + // 4. Get locations (aggregated) + const locationData = await tx + .select({ + city: sessions.geoCity, + region: sessions.geoRegion, + country: sessions.geoCountry, + lat: sessions.geoLat, + lon: sessions.geoLon, + sessionCount: sql`count(*)::int`, + lastSeenAt: sql`max(${sessions.startedAt})`, + ipAddresses: sql`array_agg(distinct ${sessions.ipAddress})`, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)) + .groupBy( + sessions.geoCity, + sessions.geoRegion, + sessions.geoCountry, + sessions.geoLat, + sessions.geoLon + ) + .orderBy(desc(sql`max(${sessions.startedAt})`)); + + const locations: UserLocation[] = locationData.map((loc) => ({ + city: loc.city, + region: loc.region, + country: loc.country, + lat: loc.lat, + lon: loc.lon, + sessionCount: loc.sessionCount, + lastSeenAt: loc.lastSeenAt, + ipAddresses: loc.ipAddresses ?? [], + })); + + // 5. Get devices (fetch sessions for device aggregation) + const deviceSessionData = await tx + .select({ + deviceId: sessions.deviceId, + playerName: sessions.playerName, + product: sessions.product, + device: sessions.device, + platform: sessions.platform, + startedAt: sessions.startedAt, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)) + .orderBy(desc(sessions.startedAt)); + + // Aggregate devices in memory (same logic as devices.ts) + const deviceMap = new Map< + string, + { + deviceId: string | null; + playerName: string | null; + product: string | null; + device: string | null; + platform: string | null; + sessionCount: number; + lastSeenAt: Date; + locationMap: Map< + string, + { + city: string | null; + region: string | null; + country: string | null; + sessionCount: number; + lastSeenAt: Date; + } + >; + } + >(); + + for (const session of deviceSessionData) { + const key = + session.deviceId ?? + session.playerName ?? + `${session.product ?? 'unknown'}-${session.device ?? 'unknown'}-${session.platform ?? 'unknown'}`; + + const existing = deviceMap.get(key); + if (existing) { + existing.sessionCount++; + if (session.startedAt > existing.lastSeenAt) { + existing.lastSeenAt = session.startedAt; + existing.playerName = session.playerName ?? existing.playerName; + existing.product = session.product ?? existing.product; + existing.device = session.device ?? existing.device; + existing.platform = session.platform ?? existing.platform; + } + + const locKey = `${session.geoCity ?? ''}-${session.geoRegion ?? ''}-${session.geoCountry ?? ''}`; + const existingLoc = existing.locationMap.get(locKey); + if (existingLoc) { + existingLoc.sessionCount++; + if (session.startedAt > existingLoc.lastSeenAt) { + existingLoc.lastSeenAt = session.startedAt; + } + } else { + existing.locationMap.set(locKey, { + city: session.geoCity, + region: session.geoRegion, + country: session.geoCountry, + sessionCount: 1, + lastSeenAt: session.startedAt, + }); + } + } else { + const locationMap = new Map< + string, + { + city: string | null; + region: string | null; + country: string | null; + sessionCount: number; + lastSeenAt: Date; + } + >(); + const locKey = `${session.geoCity ?? ''}-${session.geoRegion ?? ''}-${session.geoCountry ?? ''}`; + locationMap.set(locKey, { + city: session.geoCity, + region: session.geoRegion, + country: session.geoCountry, + sessionCount: 1, + lastSeenAt: session.startedAt, + }); + + deviceMap.set(key, { + deviceId: session.deviceId, + playerName: session.playerName, + product: session.product, + device: session.device, + platform: session.platform, + sessionCount: 1, + lastSeenAt: session.startedAt, + locationMap, + }); + } + } + + const devices: UserDevice[] = Array.from(deviceMap.values()) + .map((dev) => ({ + deviceId: dev.deviceId, + playerName: dev.playerName, + product: dev.product, + device: dev.device, + platform: dev.platform, + sessionCount: dev.sessionCount, + lastSeenAt: dev.lastSeenAt, + locations: Array.from(dev.locationMap.values()).sort( + (a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime() + ), + })) + .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime()); + + // 6. Get violations (recent, limited) + const violationData = await tx + .select({ + id: violations.id, + ruleId: violations.ruleId, + ruleName: rules.name, + ruleType: rules.type, + serverUserId: violations.serverUserId, + sessionId: violations.sessionId, + mediaTitle: sessions.mediaTitle, + severity: violations.severity, + data: violations.data, + createdAt: violations.createdAt, + acknowledgedAt: violations.acknowledgedAt, + }) + .from(violations) + .innerJoin(rules, eq(violations.ruleId, rules.id)) + .innerJoin(sessions, eq(violations.sessionId, sessions.id)) + .where(eq(violations.serverUserId, id)) + .orderBy(desc(violations.createdAt)) + .limit(violationsLimit); + + // Get violations count + const violationsCountResult = await tx + .select({ count: sql`count(*)::int` }) + .from(violations) + .where(eq(violations.serverUserId, id)); + + const violationsTotal = violationsCountResult[0]?.count ?? 0; + + // 7. Get termination history (recent, limited) + const terminationData = await tx + .select({ + id: terminationLogs.id, + sessionId: terminationLogs.sessionId, + serverId: terminationLogs.serverId, + serverUserId: terminationLogs.serverUserId, + trigger: terminationLogs.trigger, + triggeredByUserId: terminationLogs.triggeredByUserId, + triggeredByUsername: users.username, + ruleId: terminationLogs.ruleId, + ruleName: rules.name, + violationId: terminationLogs.violationId, + reason: terminationLogs.reason, + success: terminationLogs.success, + errorMessage: terminationLogs.errorMessage, + createdAt: terminationLogs.createdAt, + mediaTitle: sessions.mediaTitle, + mediaType: sessions.mediaType, + }) + .from(terminationLogs) + .leftJoin(users, eq(terminationLogs.triggeredByUserId, users.id)) + .leftJoin(rules, eq(terminationLogs.ruleId, rules.id)) + .leftJoin(sessions, eq(terminationLogs.sessionId, sessions.id)) + .where(eq(terminationLogs.serverUserId, id)) + .orderBy(desc(terminationLogs.createdAt)) + .limit(terminationsLimit); + + // Get terminations count + const terminationsCountResult = await tx + .select({ count: sql`count(*)::int` }) + .from(terminationLogs) + .where(eq(terminationLogs.serverUserId, id)); + + const terminationsTotal = terminationsCountResult[0]?.count ?? 0; + + return { + user: { + ...serverUser, + stats: { + totalSessions: stats?.totalSessions ?? 0, + totalWatchTime: Number(stats?.totalWatchTime ?? 0), + }, + }, + sessions: { + data: recentSessions, + total: stats?.totalSessions ?? 0, + hasMore: (stats?.totalSessions ?? 0) > sessionsLimit, + }, + locations, + devices, + violations: { + data: violationData.map((v) => ({ + id: v.id, + ruleId: v.ruleId, + rule: { + name: v.ruleName, + type: v.ruleType, + }, + serverUserId: v.serverUserId, + sessionId: v.sessionId, + mediaTitle: v.mediaTitle, + severity: v.severity, + data: v.data, + createdAt: v.createdAt, + acknowledgedAt: v.acknowledgedAt, + })), + total: violationsTotal, + hasMore: violationsTotal > violationsLimit, + }, + terminations: { + data: terminationData, + total: terminationsTotal, + hasMore: terminationsTotal > terminationsLimit, + }, + }; + }); + + // Handle errors from transaction + if ('error' in result) { + if (result.error === 'notFound') { + return reply.notFound('User not found'); + } + if (result.error === 'forbidden') { + return reply.forbidden('You do not have access to this user'); + } + } + + return result; + } + ); +}; diff --git a/apps/server/src/routes/users/index.ts b/apps/server/src/routes/users/index.ts new file mode 100644 index 0000000..49f1d5b --- /dev/null +++ b/apps/server/src/routes/users/index.ts @@ -0,0 +1,34 @@ +/** + * User Routes Module + * + * Orchestrates all user-related routes and provides unified export. + * + * Routes: + * - GET / - List all users with pagination + * - GET /:id - Get user details + * - PATCH /:id - Update user + * - GET /:id/full - Get complete user details (aggregate endpoint) + * - GET /:id/sessions - Get user's session history + * - GET /:id/locations - Get user's unique locations + * - GET /:id/devices - Get user's unique devices + * - GET /:id/terminations - Get user's termination history + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { listRoutes } from './list.js'; +import { fullRoutes } from './full.js'; +import { sessionsRoutes } from './sessions.js'; +import { locationsRoutes } from './locations.js'; +import { devicesRoutes } from './devices.js'; +import { terminationsRoutes } from './terminations.js'; + +export const userRoutes: FastifyPluginAsync = async (app) => { + // Register all sub-route plugins + // Each plugin defines its own paths (no additional prefix needed) + await app.register(listRoutes); + await app.register(fullRoutes); + await app.register(sessionsRoutes); + await app.register(locationsRoutes); + await app.register(devicesRoutes); + await app.register(terminationsRoutes); +}; diff --git a/apps/server/src/routes/users/list.ts b/apps/server/src/routes/users/list.ts new file mode 100644 index 0000000..061b6aa --- /dev/null +++ b/apps/server/src/routes/users/list.ts @@ -0,0 +1,323 @@ +/** + * Server User List and CRUD Routes + * + * These routes manage server users (accounts on Plex/Jellyfin/Emby servers), + * not the identity users. Server users have per-server trust scores and session counts. + * + * GET / - List all server users with pagination + * GET /:id - Get server user details + * PATCH /:id - Update server user (trustScore, etc.) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and, sql, inArray } from 'drizzle-orm'; +import { + updateUserSchema, + updateUserIdentitySchema, + userIdParamSchema, + paginationSchema, + serverIdFilterSchema, +} from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { serverUsers, sessions, servers, users } from '../../db/schema.js'; +import { hasServerAccess } from '../../utils/serverFiltering.js'; +import { updateUser } from '../../services/userService.js'; + +export const listRoutes: FastifyPluginAsync = async (app) => { + // Combined schema for pagination and server filter + const userListQuerySchema = paginationSchema.extend(serverIdFilterSchema.shape); + + /** + * GET / - List all server users with pagination + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = userListQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { page = 1, pageSize = 50, serverId } = query.data; + const authUser = request.user; + const offset = (page - 1) * pageSize; + + // If specific server requested, validate access + if (serverId && !hasServerAccess(authUser, serverId)) { + return reply.forbidden('You do not have access to this server'); + } + + // Build conditions for filtering + const conditions = []; + + // If specific server requested, filter to that server + if (serverId) { + conditions.push(eq(serverUsers.serverId, serverId)); + } else if (authUser.role !== 'owner') { + // No specific server - filter by user's accessible servers (non-owners only) + if (authUser.serverIds.length === 0) { + // No server access - return empty result + return { + data: [], + page, + pageSize, + total: 0, + totalPages: 0, + }; + } else if (authUser.serverIds.length === 1) { + conditions.push(eq(serverUsers.serverId, authUser.serverIds[0]!)); + } else { + conditions.push(inArray(serverUsers.serverId, authUser.serverIds)); + } + } + + const serverUserList = await db + .select({ + id: serverUsers.id, + serverId: serverUsers.serverId, + serverName: servers.name, + userId: serverUsers.userId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + // Include identity info + identityName: users.name, + role: users.role, + }) + .from(serverUsers) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(serverUsers.username) + .limit(pageSize) + .offset(offset); + + // Get total count + const countResult = await db + .select({ count: sql`count(*)::int` }) + .from(serverUsers) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + const total = countResult[0]?.count ?? 0; + + return { + data: serverUserList, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }; + } + ); + + /** + * GET /:id - Get server user details + */ + app.get( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const { id } = params.data; + const authUser = request.user; + + const serverUserRows = await db + .select({ + id: serverUsers.id, + serverId: serverUsers.serverId, + serverName: servers.name, + userId: serverUsers.userId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + // Include identity info + identityName: users.name, + role: users.role, + }) + .from(serverUsers) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + // Verify access (owners can see all servers) + if (!hasServerAccess(authUser, serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Get session stats for this server user + const statsResult = await db + .select({ + totalSessions: sql`count(*)::int`, + totalWatchTime: sql`coalesce(sum(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)); + + const stats = statsResult[0]; + + return { + ...serverUser, + stats: { + totalSessions: stats?.totalSessions ?? 0, + totalWatchTime: Number(stats?.totalWatchTime ?? 0), + }, + }; + } + ); + + /** + * PATCH /:id - Update server user (trustScore, etc.) + */ + app.patch( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const body = updateUserSchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can update users + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can update users'); + } + + // Get existing server user + const serverUserRows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + // Verify access (owners can see all servers) + if (!hasServerAccess(authUser, serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Build update object + const updateData: Partial<{ + trustScore: number; + updatedAt: Date; + }> = { + updatedAt: new Date(), + }; + + if (body.data.trustScore !== undefined) { + updateData.trustScore = body.data.trustScore; + } + + // Update server user + const updated = await db + .update(serverUsers) + .set(updateData) + .where(eq(serverUsers.id, id)) + .returning({ + id: serverUsers.id, + serverId: serverUsers.serverId, + userId: serverUsers.userId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + }); + + const updatedServerUser = updated[0]; + if (!updatedServerUser) { + return reply.internalServerError('Failed to update user'); + } + + return updatedServerUser; + } + ); + + /** + * PATCH /:id/identity - Update user identity (display name) + * Owner-only. Updates the users table (identity), not server_users. + */ + app.patch( + '/:id/identity', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const body = updateUserIdentitySchema.safeParse(request.body); + if (!body.success) { + return reply.badRequest('Invalid request body'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can update user identity + if (authUser.role !== 'owner') { + return reply.forbidden('Only owners can update user identity'); + } + + // Get serverUser to find userId (the identity) + const serverUserRows = await db + .select({ userId: serverUsers.userId, serverId: serverUsers.serverId }) + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + // Verify access + if (!hasServerAccess(authUser, serverUser.serverId)) { + return reply.forbidden('Access denied'); + } + + // Update the identity record (users table) + const updated = await updateUser(serverUser.userId, { name: body.data.name }); + + return { success: true, name: updated.name }; + } + ); +}; diff --git a/apps/server/src/routes/users/locations.ts b/apps/server/src/routes/users/locations.ts new file mode 100644 index 0000000..5f77c55 --- /dev/null +++ b/apps/server/src/routes/users/locations.ts @@ -0,0 +1,82 @@ +/** + * User Locations Route + * + * GET /:id/locations - Get user's unique locations (aggregated from sessions) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc, sql } from 'drizzle-orm'; +import { userIdParamSchema, type UserLocation } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { serverUsers, sessions } from '../../db/schema.js'; + +export const locationsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /:id/locations - Get user's unique locations (aggregated from sessions) + */ + app.get( + '/:id/locations', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Verify server user exists and access + const serverUserRows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + if (!authUser.serverIds.includes(serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Aggregate locations from sessions + const locationData = await db + .select({ + city: sessions.geoCity, + region: sessions.geoRegion, + country: sessions.geoCountry, + lat: sessions.geoLat, + lon: sessions.geoLon, + sessionCount: sql`count(*)::int`, + lastSeenAt: sql`max(${sessions.startedAt})`, + ipAddresses: sql`array_agg(distinct ${sessions.ipAddress})`, + }) + .from(sessions) + .where(eq(sessions.serverUserId, id)) + .groupBy( + sessions.geoCity, + sessions.geoRegion, + sessions.geoCountry, + sessions.geoLat, + sessions.geoLon + ) + .orderBy(desc(sql`max(${sessions.startedAt})`)); + + const locations: UserLocation[] = locationData.map((loc) => ({ + city: loc.city, + region: loc.region, + country: loc.country, + lat: loc.lat, + lon: loc.lon, + sessionCount: loc.sessionCount, + lastSeenAt: loc.lastSeenAt, + ipAddresses: loc.ipAddresses ?? [], + })); + + return { data: locations }; + } + ); +}; diff --git a/apps/server/src/routes/users/sessions.ts b/apps/server/src/routes/users/sessions.ts new file mode 100644 index 0000000..ae6f35a --- /dev/null +++ b/apps/server/src/routes/users/sessions.ts @@ -0,0 +1,120 @@ +/** + * User Sessions Route + * + * GET /:id/sessions - Get user's session history + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc, sql } from 'drizzle-orm'; +import { userIdParamSchema, paginationSchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { serverUsers, sessions, servers } from '../../db/schema.js'; + +export const sessionsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /:id/sessions - Get user's session history + */ + app.get( + '/:id/sessions', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const query = paginationSchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { id } = params.data; + const { page = 1, pageSize = 50 } = query.data; + const authUser = request.user; + const offset = (page - 1) * pageSize; + + // Verify server user exists and access + const serverUserRows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + if (!authUser.serverIds.includes(serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Get sessions + const sessionData = await db + .select({ + id: sessions.id, + serverId: sessions.serverId, + serverName: servers.name, + serverUserId: sessions.serverUserId, + sessionKey: sessions.sessionKey, + state: sessions.state, + mediaType: sessions.mediaType, + mediaTitle: sessions.mediaTitle, + // Media metadata for display + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + thumbPath: sessions.thumbPath, + ratingKey: sessions.ratingKey, + externalSessionId: sessions.externalSessionId, + startedAt: sessions.startedAt, + stoppedAt: sessions.stoppedAt, + durationMs: sessions.durationMs, + totalDurationMs: sessions.totalDurationMs, + progressMs: sessions.progressMs, + // Pause tracking fields + lastPausedAt: sessions.lastPausedAt, + pausedDurationMs: sessions.pausedDurationMs, + referenceId: sessions.referenceId, + watched: sessions.watched, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + deviceId: sessions.deviceId, + product: sessions.product, + device: sessions.device, + platform: sessions.platform, + quality: sessions.quality, + isTranscode: sessions.isTranscode, + bitrate: sessions.bitrate, + }) + .from(sessions) + .innerJoin(servers, eq(sessions.serverId, servers.id)) + .where(eq(sessions.serverUserId, id)) + .orderBy(desc(sessions.startedAt)) + .limit(pageSize) + .offset(offset); + + // Get total count + const countResult = await db + .select({ count: sql`count(*)::int` }) + .from(sessions) + .where(eq(sessions.serverUserId, id)); + + const total = countResult[0]?.count ?? 0; + + return { + data: sessionData, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }; + } + ); +}; diff --git a/apps/server/src/routes/users/terminations.ts b/apps/server/src/routes/users/terminations.ts new file mode 100644 index 0000000..4da3d53 --- /dev/null +++ b/apps/server/src/routes/users/terminations.ts @@ -0,0 +1,108 @@ +/** + * User Terminations Route + * + * GET /:id/terminations - Get termination history for a user + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, desc, sql } from 'drizzle-orm'; +import { userIdParamSchema, paginationSchema } from '@tracearr/shared'; +import { db } from '../../db/client.js'; +import { + serverUsers, + terminationLogs, + users, + rules, + sessions, +} from '../../db/schema.js'; + +export const terminationsRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /:id/terminations - Get termination history for a user + * + * Returns all stream terminations where this user's streams were killed, + * including who triggered it (manual) or which rule (automated). + */ + app.get( + '/:id/terminations', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = userIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid user ID'); + } + + const query = paginationSchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { id } = params.data; + const { page = 1, pageSize = 20 } = query.data; + const authUser = request.user; + const offset = (page - 1) * pageSize; + + // Verify server user exists and access + const serverUserRows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.id, id)) + .limit(1); + + const serverUser = serverUserRows[0]; + if (!serverUser) { + return reply.notFound('User not found'); + } + + if (!authUser.serverIds.includes(serverUser.serverId)) { + return reply.forbidden('You do not have access to this user'); + } + + // Get termination logs with joined data + const terminations = await db + .select({ + id: terminationLogs.id, + sessionId: terminationLogs.sessionId, + serverId: terminationLogs.serverId, + serverUserId: terminationLogs.serverUserId, + trigger: terminationLogs.trigger, + triggeredByUserId: terminationLogs.triggeredByUserId, + triggeredByUsername: users.username, + ruleId: terminationLogs.ruleId, + ruleName: rules.name, + violationId: terminationLogs.violationId, + reason: terminationLogs.reason, + success: terminationLogs.success, + errorMessage: terminationLogs.errorMessage, + createdAt: terminationLogs.createdAt, + // Session info + mediaTitle: sessions.mediaTitle, + mediaType: sessions.mediaType, + }) + .from(terminationLogs) + .leftJoin(users, eq(terminationLogs.triggeredByUserId, users.id)) + .leftJoin(rules, eq(terminationLogs.ruleId, rules.id)) + .leftJoin(sessions, eq(terminationLogs.sessionId, sessions.id)) + .where(eq(terminationLogs.serverUserId, id)) + .orderBy(desc(terminationLogs.createdAt)) + .limit(pageSize) + .offset(offset); + + // Get total count + const countResult = await db + .select({ count: sql`count(*)::int` }) + .from(terminationLogs) + .where(eq(terminationLogs.serverUserId, id)); + + const total = countResult[0]?.count ?? 0; + + return { + data: terminations, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }; + } + ); +}; diff --git a/apps/server/src/routes/violations.ts b/apps/server/src/routes/violations.ts new file mode 100644 index 0000000..e16a78f --- /dev/null +++ b/apps/server/src/routes/violations.ts @@ -0,0 +1,798 @@ +/** + * Violation management routes + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { eq, and, desc, gte, lte, isNull, isNotNull, sql, inArray } from 'drizzle-orm'; +import { + violationQuerySchema, + violationIdParamSchema, + type ViolationSessionInfo, +} from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { violations, rules, serverUsers, sessions, servers, users } from '../db/schema.js'; +import { hasServerAccess } from '../utils/serverFiltering.js'; +import { getTrustScorePenalty } from '../jobs/poller/violations.js'; + +export const violationRoutes: FastifyPluginAsync = async (app) => { + /** + * GET /violations - List violations with pagination and filters + * + * Violations are filtered by server access. Users only see violations + * from servers they have access to. + */ + app.get( + '/', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const query = violationQuerySchema.safeParse(request.query); + if (!query.success) { + return reply.badRequest('Invalid query parameters'); + } + + const { + page = 1, + pageSize = 50, + serverId, + serverUserId, + ruleId, + severity, + acknowledged, + startDate, + endDate, + } = query.data; + + const authUser = request.user; + const offset = (page - 1) * pageSize; + + // Validate server access if specific server requested + if (serverId && !hasServerAccess(authUser, serverId)) { + return reply.forbidden('You do not have access to this server'); + } + + // Build conditions + const conditions = []; + + // Server filter - either specific server or user's accessible servers + if (serverId) { + // Specific server requested + conditions.push(eq(serverUsers.serverId, serverId)); + } else if (authUser.role !== 'owner') { + // No specific server, filter by user's accessible servers + if (authUser.serverIds.length === 0) { + // No server access - return empty + return { + data: [], + page, + pageSize, + total: 0, + totalPages: 0, + }; + } else if (authUser.serverIds.length === 1) { + const serverId = authUser.serverIds[0]; + if (serverId) { + conditions.push(eq(serverUsers.serverId, serverId)); + } + } else { + conditions.push(inArray(serverUsers.serverId, authUser.serverIds)); + } + } + + if (serverUserId) { + conditions.push(eq(violations.serverUserId, serverUserId)); + } + + if (ruleId) { + conditions.push(eq(violations.ruleId, ruleId)); + } + + if (severity) { + conditions.push(eq(violations.severity, severity)); + } + + if (acknowledged === true) { + conditions.push(isNotNull(violations.acknowledgedAt)); + } else if (acknowledged === false) { + conditions.push(isNull(violations.acknowledgedAt)); + } + + if (startDate) { + conditions.push(gte(violations.createdAt, startDate)); + } + + if (endDate) { + conditions.push(lte(violations.createdAt, endDate)); + } + + // Query violations with joins, including server info and session details + const violationData = await db + .select({ + id: violations.id, + ruleId: violations.ruleId, + ruleName: rules.name, + ruleType: rules.type, + serverUserId: violations.serverUserId, + username: serverUsers.username, + userThumb: serverUsers.thumbUrl, + identityName: users.name, + serverId: serverUsers.serverId, + serverName: servers.name, + sessionId: violations.sessionId, + // Session details for context + mediaTitle: sessions.mediaTitle, + mediaType: sessions.mediaType, + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + device: sessions.device, + deviceId: sessions.deviceId, + platform: sessions.platform, + product: sessions.product, + quality: sessions.quality, + startedAt: sessions.startedAt, + severity: violations.severity, + data: violations.data, + createdAt: violations.createdAt, + acknowledgedAt: violations.acknowledgedAt, + }) + .from(violations) + .innerJoin(rules, eq(violations.ruleId, rules.id)) + .innerJoin(serverUsers, eq(violations.serverUserId, serverUsers.id)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .innerJoin(sessions, eq(violations.sessionId, sessions.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(violations.createdAt)) + .limit(pageSize) + .offset(offset); + + // Get total count with same filters + // Need to use raw SQL for count with the same joins + const countConditions = []; + + // Server filter for count query + if (serverId) { + countConditions.push(sql`su.server_id = ${serverId}`); + } else if (authUser.role !== 'owner') { + if (authUser.serverIds.length === 1) { + countConditions.push(sql`su.server_id = ${authUser.serverIds[0]}`); + } else if (authUser.serverIds.length > 1) { + const serverIdList = authUser.serverIds.map((id: string) => sql`${id}`); + countConditions.push(sql`su.server_id IN (${sql.join(serverIdList, sql`, `)})`); + } + } + + if (serverUserId) { + countConditions.push(sql`v.server_user_id = ${serverUserId}`); + } + + if (ruleId) { + countConditions.push(sql`v.rule_id = ${ruleId}`); + } + + if (severity) { + countConditions.push(sql`v.severity = ${severity}`); + } + + if (acknowledged === true) { + countConditions.push(sql`v.acknowledged_at IS NOT NULL`); + } else if (acknowledged === false) { + countConditions.push(sql`v.acknowledged_at IS NULL`); + } + + if (startDate) { + countConditions.push(sql`v.created_at >= ${startDate}`); + } + + if (endDate) { + countConditions.push(sql`v.created_at <= ${endDate}`); + } + + const whereClause = countConditions.length > 0 + ? sql`WHERE ${sql.join(countConditions, sql` AND `)}` + : sql``; + + const countResult = await db.execute(sql` + SELECT count(*)::int as count + FROM violations v + INNER JOIN server_users su ON su.id = v.server_user_id + ${whereClause} + `); + + const total = (countResult.rows[0] as { count: number })?.count ?? 0; + + // Identify violations that need historical/related data to batch queries + const violationsNeedingData = violationData.filter((v) => + ['concurrent_streams', 'simultaneous_locations', 'device_velocity'].includes(v.ruleType) + ); + + // Collect all relatedSessionIds from violation data for direct lookup + const allRelatedSessionIds = new Set(); + for (const v of violationsNeedingData) { + const vData = v.data as Record | null; + const relatedIds = (vData?.relatedSessionIds as string[]) || []; + for (const id of relatedIds) { + allRelatedSessionIds.add(id); + } + } + + // Batch fetch historical data by serverUserId to avoid N+1 queries + const historicalDataByUserId = new Map< + string, + Array<{ + ipAddress: string; + deviceId: string | null; + device: string | null; + geoCity: string | null; + geoCountry: string | null; + startedAt: Date; + }> + >(); + + // Batch fetch related sessions by (serverUserId, ruleType) to avoid N+1 queries + const relatedSessionsByViolation = new Map(); + + // Map to store fetched sessions by ID for direct lookup from relatedSessionIds + const sessionsById = new Map(); + + // Wrap batching in try-catch to handle errors gracefully (e.g., in tests or when queries fail) + try { + if (violationsNeedingData.length > 0) { + // Group violations by serverUserId and find the oldest violation time for each user + const userViolationTimes = new Map(); + for (const v of violationsNeedingData) { + const existing = userViolationTimes.get(v.serverUserId); + if (!existing || v.createdAt < existing) { + userViolationTimes.set(v.serverUserId, v.createdAt); + } + } + + // Batch fetch historical sessions for each unique serverUserId + // Go back 30 days from the oldest violation time for each user + const historicalPromises = Array.from(userViolationTimes.entries()).map( + async ([serverUserId, oldestViolationTime]) => { + try { + const historyWindow = new Date(oldestViolationTime.getTime() - 30 * 24 * 60 * 60 * 1000); + const historicalSessions = await db + .select({ + ipAddress: sessions.ipAddress, + deviceId: sessions.deviceId, + device: sessions.device, + geoCity: sessions.geoCity, + geoCountry: sessions.geoCountry, + startedAt: sessions.startedAt, + }) + .from(sessions) + .where( + and( + eq(sessions.serverUserId, serverUserId), + gte(sessions.startedAt, historyWindow), + lte(sessions.startedAt, oldestViolationTime) + ) + ) + .limit(1000); // Get enough to build a good history + + return [serverUserId, historicalSessions] as const; + } catch (error) { + // If query fails (e.g., in tests), return empty array for this user + console.error(`[Violations] Failed to fetch historical data for user ${serverUserId}:`, error); + const emptyArray: Array<{ + ipAddress: string; + deviceId: string | null; + device: string | null; + geoCity: string | null; + geoCountry: string | null; + startedAt: Date; + }> = []; + return [serverUserId, emptyArray] as const; + } + } + ); + + const historicalResults = await Promise.allSettled(historicalPromises); + for (const result of historicalResults) { + if (result.status === 'fulfilled') { + const [serverUserId, sessions] = result.value; + historicalDataByUserId.set(serverUserId, sessions); + } + // If rejected, that user just won't have historical data (already handled in catch) + } + } + + // Batch fetch sessions by ID from relatedSessionIds stored in violation data + if (allRelatedSessionIds.size > 0) { + try { + const relatedSessionsResult = await db + .select({ + id: sessions.id, + mediaTitle: sessions.mediaTitle, + mediaType: sessions.mediaType, + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + device: sessions.device, + deviceId: sessions.deviceId, + platform: sessions.platform, + product: sessions.product, + quality: sessions.quality, + startedAt: sessions.startedAt, + }) + .from(sessions) + .where(inArray(sessions.id, Array.from(allRelatedSessionIds))); + + for (const s of relatedSessionsResult) { + sessionsById.set(s.id, { + ...s, + deviceId: s.deviceId ?? null, + }); + } + } catch (error) { + console.error('[Violations] Failed to batch fetch related sessions by ID:', error); + // Continue without related sessions - fallback to time-based logic + } + } + + if (violationsNeedingData.length > 0) { + // Group violations by (serverUserId, ruleType) and find time ranges + const violationGroups = new Map< + string, + { violations: Array<{ id: string; createdAt: Date }>; earliestTime: Date; latestTime: Date } + >(); + + for (const v of violationsNeedingData) { + const key = `${v.serverUserId}:${v.ruleType}`; + const existing = violationGroups.get(key); + const violationTime = v.createdAt; + const timeWindow = new Date(violationTime.getTime() - 5 * 60 * 1000); // 5 minutes before violation + + if (existing) { + existing.violations.push({ id: v.id, createdAt: violationTime }); + if (timeWindow < existing.earliestTime) { + existing.earliestTime = timeWindow; + } + if (violationTime > existing.latestTime) { + existing.latestTime = violationTime; + } + } else { + violationGroups.set(key, { + violations: [{ id: v.id, createdAt: violationTime }], + earliestTime: timeWindow, + latestTime: violationTime, + }); + } + } + + // Batch fetch related sessions for each group + const relatedSessionsPromises = Array.from(violationGroups.entries()).map( + async ([key, group]) => { + const parts = key.split(':'); + const serverUserId = parts[0]; + const ruleType = parts[1]; + if (!serverUserId || !ruleType) { + console.error(`[Violations] Invalid key format: ${key}`); + // Mark all violations in this group as having no related sessions + for (const violation of group.violations) { + relatedSessionsByViolation.set(violation.id, []); + } + return; + } + const conditions = [ + eq(sessions.serverUserId, serverUserId), + gte(sessions.startedAt, group.earliestTime), + lte(sessions.startedAt, group.latestTime), + ]; + + // Add rule-type-specific conditions + if (ruleType === 'concurrent_streams') { + conditions.push(eq(sessions.state, 'playing')); + conditions.push(isNull(sessions.stoppedAt)); + } else if (ruleType === 'simultaneous_locations') { + conditions.push(eq(sessions.state, 'playing')); + conditions.push(isNull(sessions.stoppedAt)); + conditions.push(isNotNull(sessions.geoLat)); + conditions.push(isNotNull(sessions.geoLon)); + } + // device_velocity has no additional conditions + + try { + const sessionsResult = await db + .select({ + id: sessions.id, + mediaTitle: sessions.mediaTitle, + mediaType: sessions.mediaType, + grandparentTitle: sessions.grandparentTitle, + seasonNumber: sessions.seasonNumber, + episodeNumber: sessions.episodeNumber, + year: sessions.year, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoRegion: sessions.geoRegion, + geoCountry: sessions.geoCountry, + geoLat: sessions.geoLat, + geoLon: sessions.geoLon, + playerName: sessions.playerName, + device: sessions.device, + deviceId: sessions.deviceId, + platform: sessions.platform, + product: sessions.product, + quality: sessions.quality, + startedAt: sessions.startedAt, + }) + .from(sessions) + .where(and(...conditions)) + .orderBy(desc(sessions.startedAt)) + .limit(100); // Fetch more to cover all violations in the group + + const mappedSessions = sessionsResult.map((s) => ({ + ...s, + deviceId: s.deviceId ?? null, + })); + + // Filter sessions to each violation's specific 5-minute window + for (const violation of group.violations) { + const violationTime = violation.createdAt; + const timeWindow = new Date(violationTime.getTime() - 5 * 60 * 1000); + const violationSessions = mappedSessions + .filter((s) => s.startedAt >= timeWindow && s.startedAt <= violationTime) + .slice(0, 20); // Limit to 20 per violation + relatedSessionsByViolation.set(violation.id, violationSessions); + } + } catch (error) { + // If fetching fails, mark all violations in this group as having no related sessions + console.error(`[Violations] Failed to fetch related sessions for group ${key}:`, error); + for (const violation of group.violations) { + relatedSessionsByViolation.set(violation.id, []); + } + } + } + ); + + await Promise.allSettled(relatedSessionsPromises); + // Errors are already handled in individual try-catch blocks + } + } catch (error) { + // If batching fails (e.g., in tests or when queries fail), continue without extra data + // This prevents the entire violation list from failing + console.error('[Violations] Failed to batch fetch historical/related data:', error); + } + + // Transform flat data into nested structure expected by frontend + const formattedData = violationData.map((v) => { + // Fetch related sessions - prioritize using relatedSessionIds from violation data + // This is more accurate than time-based queries + const vData = v.data as Record | null; + const relatedSessionIdsFromData = (vData?.relatedSessionIds as string[]) || []; + + let relatedSessions: ViolationSessionInfo[] = []; + if (relatedSessionIdsFromData.length > 0) { + // Use the stored relatedSessionIds for direct lookup (preferred) + relatedSessions = relatedSessionIdsFromData + .map((id) => sessionsById.get(id)) + .filter((s): s is ViolationSessionInfo => s !== undefined); + } else { + // Fallback to time-based query results for older violations + relatedSessions = relatedSessionsByViolation.get(v.id) ?? []; + } + + // For concurrent_streams, simultaneous_locations, and device_velocity, fetch related sessions + // Also fetch user's historical data for comparison + let userHistory: { + previousIPs: string[]; + previousDevices: string[]; + previousLocations: Array<{ city: string | null; country: string | null; ip: string }>; + } = { + previousIPs: [], + previousDevices: [], + previousLocations: [], + }; + + if (['concurrent_streams', 'simultaneous_locations', 'device_velocity'].includes(v.ruleType)) { + const violationTime = v.createdAt; + + // Use batched historical data, filtered to this violation's time window + const allHistoricalSessions = historicalDataByUserId.get(v.serverUserId) ?? []; + const historicalSessions = allHistoricalSessions.filter( + (s) => s.startedAt >= new Date(violationTime.getTime() - 30 * 24 * 60 * 60 * 1000) && s.startedAt <= violationTime + ); + + // Build unique sets of previous values + const ipSet = new Set(); + const deviceSet = new Set(); + const locationMap = new Map(); + + for (const hist of historicalSessions) { + if (hist.ipAddress) ipSet.add(hist.ipAddress); + if (hist.deviceId) deviceSet.add(hist.deviceId); + if (hist.device) deviceSet.add(hist.device); + if (hist.geoCity || hist.geoCountry) { + const locKey = `${hist.geoCity ?? ''}-${hist.geoCountry ?? ''}`; + if (!locationMap.has(locKey)) { + locationMap.set(locKey, { + city: hist.geoCity, + country: hist.geoCountry, + ip: hist.ipAddress, + }); + } + } + } + + userHistory = { + previousIPs: Array.from(ipSet), + previousDevices: Array.from(deviceSet), + previousLocations: Array.from(locationMap.values()), + }; + } + + return { + id: v.id, + ruleId: v.ruleId, + serverUserId: v.serverUserId, + sessionId: v.sessionId, + severity: v.severity, + data: v.data, + createdAt: v.createdAt, + acknowledgedAt: v.acknowledgedAt, + rule: { + id: v.ruleId, + name: v.ruleName, + type: v.ruleType, + }, + user: { + id: v.serverUserId, + username: v.username, + thumbUrl: v.userThumb, + serverId: v.serverId, + identityName: v.identityName, + }, + server: { + id: v.serverId, + name: v.serverName, + }, + session: { + id: v.sessionId, + mediaTitle: v.mediaTitle, + mediaType: v.mediaType, + grandparentTitle: v.grandparentTitle, + seasonNumber: v.seasonNumber, + episodeNumber: v.episodeNumber, + year: v.year, + ipAddress: v.ipAddress, + geoCity: v.geoCity, + geoRegion: v.geoRegion, + geoCountry: v.geoCountry, + geoLat: v.geoLat, + geoLon: v.geoLon, + playerName: v.playerName, + device: v.device, + deviceId: v.deviceId ?? null, + platform: v.platform, + product: v.product, + quality: v.quality, + startedAt: v.startedAt, + }, + relatedSessions: relatedSessions.length > 0 ? relatedSessions : undefined, + userHistory: Object.keys(userHistory.previousIPs).length > 0 || + Object.keys(userHistory.previousDevices).length > 0 || + userHistory.previousLocations.length > 0 ? userHistory : undefined, + }; + }); + + return { + data: formattedData, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }; + } + ); + + /** + * GET /violations/:id - Get a specific violation + */ + app.get( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = violationIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid violation ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Query with server info for access check + const violationRows = await db + .select({ + id: violations.id, + ruleId: violations.ruleId, + ruleName: rules.name, + ruleType: rules.type, + serverUserId: violations.serverUserId, + username: serverUsers.username, + userThumb: serverUsers.thumbUrl, + identityName: users.name, + serverId: serverUsers.serverId, + serverName: servers.name, + sessionId: violations.sessionId, + mediaTitle: sessions.mediaTitle, + ipAddress: sessions.ipAddress, + geoCity: sessions.geoCity, + geoCountry: sessions.geoCountry, + playerName: sessions.playerName, + platform: sessions.platform, + severity: violations.severity, + data: violations.data, + createdAt: violations.createdAt, + acknowledgedAt: violations.acknowledgedAt, + }) + .from(violations) + .innerJoin(rules, eq(violations.ruleId, rules.id)) + .innerJoin(serverUsers, eq(violations.serverUserId, serverUsers.id)) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .innerJoin(sessions, eq(violations.sessionId, sessions.id)) + .where(eq(violations.id, id)) + .limit(1); + + const violation = violationRows[0]; + if (!violation) { + return reply.notFound('Violation not found'); + } + + // Check server access + if (!hasServerAccess(authUser, violation.serverId)) { + return reply.forbidden('You do not have access to this violation'); + } + + return violation; + } + ); + + /** + * PATCH /violations/:id - Acknowledge a violation + */ + app.patch( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = violationIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid violation ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can acknowledge violations + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can acknowledge violations'); + } + + // Check violation exists and get server info for access check + const violationRows = await db + .select({ + id: violations.id, + serverId: serverUsers.serverId, + }) + .from(violations) + .innerJoin(serverUsers, eq(violations.serverUserId, serverUsers.id)) + .where(eq(violations.id, id)) + .limit(1); + + const violation = violationRows[0]; + if (!violation) { + return reply.notFound('Violation not found'); + } + + // Check server access + if (!hasServerAccess(authUser, violation.serverId)) { + return reply.forbidden('You do not have access to this violation'); + } + + // Update acknowledgment + const updated = await db + .update(violations) + .set({ + acknowledgedAt: new Date(), + }) + .where(eq(violations.id, id)) + .returning({ + id: violations.id, + acknowledgedAt: violations.acknowledgedAt, + }); + + const updatedViolation = updated[0]; + if (!updatedViolation) { + return reply.internalServerError('Failed to acknowledge violation'); + } + + return { + success: true, + acknowledgedAt: updatedViolation.acknowledgedAt, + }; + } + ); + + /** + * DELETE /violations/:id - Dismiss (delete) a violation + */ + app.delete( + '/:id', + { preHandler: [app.authenticate] }, + async (request, reply) => { + const params = violationIdParamSchema.safeParse(request.params); + if (!params.success) { + return reply.badRequest('Invalid violation ID'); + } + + const { id } = params.data; + const authUser = request.user; + + // Only owners can delete violations + if (authUser.role !== 'owner') { + return reply.forbidden('Only server owners can dismiss violations'); + } + + // Check violation exists and get info needed for trust score restoration + const violationRows = await db + .select({ + id: violations.id, + severity: violations.severity, + serverUserId: violations.serverUserId, + serverId: serverUsers.serverId, + }) + .from(violations) + .innerJoin(serverUsers, eq(violations.serverUserId, serverUsers.id)) + .where(eq(violations.id, id)) + .limit(1); + + const violation = violationRows[0]; + if (!violation) { + return reply.notFound('Violation not found'); + } + + // Check server access + if (!hasServerAccess(authUser, violation.serverId)) { + return reply.forbidden('You do not have access to this violation'); + } + + // Calculate trust penalty to restore + const trustPenalty = getTrustScorePenalty(violation.severity); + + // Delete violation and restore trust score atomically + await db.transaction(async (tx) => { + // Delete the violation + await tx.delete(violations).where(eq(violations.id, id)); + + // Restore trust score (capped at 100) + await tx + .update(serverUsers) + .set({ + trustScore: sql`LEAST(100, ${serverUsers.trustScore} + ${trustPenalty})`, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, violation.serverUserId)); + }); + + return { success: true }; + } + ); +}; diff --git a/apps/server/src/services/__tests__/cache.test.ts b/apps/server/src/services/__tests__/cache.test.ts new file mode 100644 index 0000000..ab17fbb --- /dev/null +++ b/apps/server/src/services/__tests__/cache.test.ts @@ -0,0 +1,921 @@ +/** + * Cache Service Tests + * + * Tests the ACTUAL createCacheService and createPubSubService from cache.ts: + * - CacheService: Redis-backed caching for sessions, stats, etc. + * - PubSubService: Pub/sub for real-time events + * + * These tests validate: + * - Get/set operations with mock Redis + * - JSON parsing error handling + * - Pattern-based invalidation + * - Set operations for user sessions + * - Pub/sub message routing + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Redis } from 'ioredis'; + +// Import ACTUAL production functions - not local duplicates +import { + createCacheService, + createPubSubService, + getPubSubService, + type CacheService, + type PubSubService, +} from '../cache.js'; + +// Mock Redis instance factory with pipeline support +function createMockRedis(): Redis & { + store: Map; + sets: Map>; + ttls: Map; +} { + const store = new Map(); + const sets = new Map>(); + const ttls = new Map(); + const messageCallbacks: Array<(channel: string, message: string) => void> = []; + + // Pipeline mock - accumulates commands and executes them atomically + const createPipeline = () => { + const commands: Array<{ cmd: string; args: unknown[] }> = []; + + const pipeline = { + sadd: (key: string, ...members: string[]) => { + commands.push({ cmd: 'sadd', args: [key, ...members] }); + return pipeline; + }, + srem: (key: string, ...members: string[]) => { + commands.push({ cmd: 'srem', args: [key, ...members] }); + return pipeline; + }, + setex: (key: string, seconds: number, value: string) => { + commands.push({ cmd: 'setex', args: [key, seconds, value] }); + return pipeline; + }, + del: (...keys: string[]) => { + commands.push({ cmd: 'del', args: keys }); + return pipeline; + }, + expire: (key: string, seconds: number) => { + commands.push({ cmd: 'expire', args: [key, seconds] }); + return pipeline; + }, + exec: vi.fn(async () => { + const results: Array<[null, unknown]> = []; + for (const { cmd, args } of commands) { + let result: unknown = 'OK'; + if (cmd === 'sadd') { + const [key, ...members] = args as [string, ...string[]]; + if (!sets.has(key)) sets.set(key, new Set()); + const set = sets.get(key)!; + let added = 0; + for (const member of members) { + if (!set.has(member)) { + set.add(member); + added++; + } + } + result = added; + } else if (cmd === 'srem') { + const [key, ...members] = args as [string, ...string[]]; + const set = sets.get(key); + let removed = 0; + if (set) { + for (const member of members) { + if (set.delete(member)) removed++; + } + } + result = removed; + } else if (cmd === 'setex') { + const [key, seconds, value] = args as [string, number, string]; + store.set(key, value); + ttls.set(key, seconds); + result = 'OK'; + } else if (cmd === 'del') { + let count = 0; + for (const key of args as string[]) { + if (store.delete(key) || sets.delete(key)) count++; + } + result = count; + } else if (cmd === 'expire') { + const [key, seconds] = args as [string, number]; + ttls.set(key, seconds); + result = store.has(key) || sets.has(key) ? 1 : 0; + } + results.push([null, result]); + } + return results; + }), + }; + return pipeline; + }; + + return { + store, + sets, + ttls, + // String operations + get: vi.fn(async (key: string) => store.get(key) ?? null), + setex: vi.fn(async (key: string, seconds: number, value: string) => { + store.set(key, value); + ttls.set(key, seconds); + return 'OK'; + }), + del: vi.fn(async (...keys: string[]) => { + let count = 0; + for (const key of keys) { + if (store.delete(key) || sets.delete(key)) count++; + } + return count; + }), + keys: vi.fn(async (pattern: string) => { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return Array.from(store.keys()).filter((k) => regex.test(k)); + }), + mget: vi.fn(async (...keys: string[]) => { + return keys.map((key) => store.get(key) ?? null); + }), + exists: vi.fn(async (key: string) => { + return store.has(key) ? 1 : 0; + }), + + // Set operations + smembers: vi.fn(async (key: string) => { + const set = sets.get(key); + return set ? Array.from(set) : []; + }), + sadd: vi.fn(async (key: string, ...members: string[]) => { + if (!sets.has(key)) sets.set(key, new Set()); + const set = sets.get(key)!; + let added = 0; + for (const member of members) { + if (!set.has(member)) { + set.add(member); + added++; + } + } + return added; + }), + srem: vi.fn(async (key: string, ...members: string[]) => { + const set = sets.get(key); + if (!set) return 0; + let removed = 0; + for (const member of members) { + if (set.delete(member)) removed++; + } + return removed; + }), + expire: vi.fn(async (key: string, seconds: number) => { + ttls.set(key, seconds); + return store.has(key) || sets.has(key) ? 1 : 0; + }), + + // Pipeline/transaction support + multi: vi.fn(() => createPipeline()), + + // Pub/Sub + publish: vi.fn(async () => 1), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + on: vi.fn((event: string, callback: (channel: string, message: string) => void) => { + if (event === 'message') { + messageCallbacks.push(callback); + } + }), + + // Health + ping: vi.fn(async () => 'PONG'), + + // Helper to simulate incoming message + _simulateMessage: (channel: string, message: string) => { + for (const cb of messageCallbacks) { + cb(channel, message); + } + }, + } as unknown as Redis & { + store: Map; + sets: Map>; + ttls: Map; + }; +} + +// Sample data matching shared types +const sampleSession = { + sessionId: 'session-123', + mediaServerId: 'server-1', + userId: 'user-123', + username: 'testuser', + title: 'Test Movie', + mediaType: 'movie' as const, + state: 'playing' as const, + progress: 50, + duration: 7200, + startTime: Date.now(), + lastUpdated: Date.now(), + device: 'Chrome', + player: 'Web', + quality: '1080p', + ipAddress: '192.168.1.100', +}; + +// Sample ActiveSession for atomic method tests (matches actual ActiveSession type) +function createTestActiveSession(id: string, serverId = 'server-1'): any { + return { + id, + sessionKey: `session-key-${id}`, + serverId, + serverUserId: 'user-123', + state: 'playing', + mediaType: 'movie', + mediaTitle: 'Test Movie', + grandparentTitle: null, + seasonNumber: null, + episodeNumber: null, + year: 2024, + thumbPath: '/library/metadata/123/thumb', + ratingKey: 'media-123', + externalSessionId: null, + startedAt: new Date(), + stoppedAt: null, + durationMs: 0, + progressMs: 0, + totalDurationMs: 7200000, + lastPausedAt: null, + pausedDurationMs: 0, + referenceId: null, + watched: false, + ipAddress: '192.168.1.100', + geoCity: 'New York', + geoRegion: 'NY', + geoCountry: 'US', + geoLat: 40.7128, + geoLon: -74.006, + playerName: 'Chrome', + deviceId: 'device-123', + product: 'Plex Web', + device: 'Chrome', + platform: 'Chrome', + quality: '1080p', + isTranscode: false, + bitrate: 20000, + user: { id: 'user-123', username: 'testuser', thumbUrl: null }, + server: { id: serverId, name: 'Test Server', type: 'plex' }, + }; +} + +const sampleStats = { + activeSessions: 5, + totalUsers: 100, + totalServers: 3, + activeViolations: 2, + sessionsToday: 25, + streamsByMediaType: { movie: 10, episode: 15 }, +}; + +describe('CacheService', () => { + let redis: ReturnType; + let cache: CacheService; + + beforeEach(() => { + redis = createMockRedis(); + cache = createCacheService(redis); + }); + + describe('getActiveSessions / setActiveSessions', () => { + it('should return null when no sessions cached', async () => { + const result = await cache.getActiveSessions(); + + expect(result).toBeNull(); + expect(redis.get).toHaveBeenCalledWith('tracearr:sessions:active'); + }); + + it('should store and retrieve active sessions', async () => { + const sessions = [sampleSession] as unknown[]; + + await cache.setActiveSessions(sessions as never); + const result = await cache.getActiveSessions(); + + expect(result).toEqual(sessions); + expect(redis.setex).toHaveBeenCalledWith( + 'tracearr:sessions:active', + 300, // CACHE_TTL.ACTIVE_SESSIONS + expect.any(String) + ); + }); + + it('should invalidate dashboard stats when setting sessions', async () => { + await cache.setActiveSessions([sampleSession] as never); + + expect(redis.del).toHaveBeenCalledWith('tracearr:stats:dashboard'); + }); + + it('should return null on JSON parse error', async () => { + redis.store.set('tracearr:sessions:active', 'not-valid-json{'); + + const result = await cache.getActiveSessions(); + + expect(result).toBeNull(); + }); + + it('should handle empty array', async () => { + await cache.setActiveSessions([]); + const result = await cache.getActiveSessions(); + + expect(result).toEqual([]); + }); + }); + + describe('getDashboardStats / setDashboardStats', () => { + it('should return null when no stats cached', async () => { + const result = await cache.getDashboardStats(); + + expect(result).toBeNull(); + }); + + it('should store and retrieve dashboard stats', async () => { + await cache.setDashboardStats(sampleStats as any); + const result = await cache.getDashboardStats(); + + expect(result).toEqual(sampleStats); + expect(redis.setex).toHaveBeenCalledWith( + 'tracearr:stats:dashboard', + 60, // CACHE_TTL.DASHBOARD_STATS + expect.any(String) + ); + }); + + it('should return null on JSON parse error', async () => { + redis.store.set('tracearr:stats:dashboard', '{broken'); + + const result = await cache.getDashboardStats(); + + expect(result).toBeNull(); + }); + }); + + describe('getSessionById / setSessionById / deleteSessionById', () => { + it('should return null for non-existent session', async () => { + const result = await cache.getSessionById('nonexistent'); + + expect(result).toBeNull(); + expect(redis.get).toHaveBeenCalledWith('tracearr:sessions:nonexistent'); + }); + + it('should store and retrieve session by ID', async () => { + await cache.setSessionById('session-123', sampleSession as any); + const result = await cache.getSessionById('session-123'); + + expect(result).toEqual(sampleSession); + }); + + it('should delete session by ID', async () => { + await cache.setSessionById('session-123', sampleSession as any); + await cache.deleteSessionById('session-123'); + + const result = await cache.getSessionById('session-123'); + expect(result).toBeNull(); + }); + + it('should return null on JSON parse error', async () => { + redis.store.set('tracearr:sessions:session-123', 'invalid-json'); + + const result = await cache.getSessionById('session-123'); + + expect(result).toBeNull(); + }); + }); + + describe('getUserSessions / addUserSession / removeUserSession', () => { + it('should return null for user with no sessions', async () => { + const result = await cache.getUserSessions('user-123'); + + expect(result).toBeNull(); + }); + + it('should add and retrieve user sessions', async () => { + await cache.addUserSession('user-123', 'session-1'); + await cache.addUserSession('user-123', 'session-2'); + + const result = await cache.getUserSessions('user-123'); + + expect(result).toContain('session-1'); + expect(result).toContain('session-2'); + expect(result).toHaveLength(2); + }); + + it('should set expiration when adding session', async () => { + await cache.addUserSession('user-123', 'session-1'); + + expect(redis.expire).toHaveBeenCalledWith('tracearr:users:user-123:sessions', 3600); + }); + + it('should remove user session', async () => { + await cache.addUserSession('user-123', 'session-1'); + await cache.addUserSession('user-123', 'session-2'); + + await cache.removeUserSession('user-123', 'session-1'); + + const result = await cache.getUserSessions('user-123'); + expect(result).toEqual(['session-2']); + }); + + it('should not add duplicate session IDs', async () => { + await cache.addUserSession('user-123', 'session-1'); + await cache.addUserSession('user-123', 'session-1'); + + const result = await cache.getUserSessions('user-123'); + expect(result).toEqual(['session-1']); + }); + }); + + describe('invalidateCache', () => { + it('should delete specific key', async () => { + redis.store.set('some:key', 'value'); + + await cache.invalidateCache('some:key'); + + expect(redis.del).toHaveBeenCalledWith('some:key'); + }); + }); + + describe('invalidatePattern', () => { + it('should delete all keys matching pattern', async () => { + redis.store.set('tracearr:sessions:1', 'data1'); + redis.store.set('tracearr:sessions:2', 'data2'); + redis.store.set('tracearr:users:1', 'user1'); + + await cache.invalidatePattern('tracearr:sessions:*'); + + // del should be called with both session keys + expect(redis.del).toHaveBeenCalled(); + // Verify keys were found + expect(redis.keys).toHaveBeenCalledWith('tracearr:sessions:*'); + }); + + it('should not call del when no keys match pattern', async () => { + await cache.invalidatePattern('nonexistent:*'); + + expect(redis.keys).toHaveBeenCalledWith('nonexistent:*'); + // del should not be called since no keys matched + expect(redis.del).not.toHaveBeenCalled(); + }); + }); + + describe('ping', () => { + it('should return true when Redis responds with PONG', async () => { + const result = await cache.ping(); + + expect(result).toBe(true); + expect(redis.ping).toHaveBeenCalled(); + }); + + it('should return false when Redis responds with non-PONG', async () => { + vi.mocked(redis.ping).mockResolvedValueOnce('ERROR'); + + const result = await cache.ping(); + + expect(result).toBe(false); + }); + + it('should return false when Redis throws', async () => { + vi.mocked(redis.ping).mockRejectedValueOnce(new Error('Connection failed')); + + const result = await cache.ping(); + + expect(result).toBe(false); + }); + }); + + // ============================================================================ + // Atomic SET-based Session Operations (Race Condition Fix) + // These tests verify the atomic operations that fix duplicate session bugs + // ============================================================================ + + describe('addActiveSession (atomic)', () => { + it('should add session to SET and store session data atomically', async () => { + const session = createTestActiveSession('session-1'); + + await cache.addActiveSession(session); + + // Verify session ID was added to SET + const ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).toContain('session-1'); + + // Verify session data was stored + const storedData = redis.store.get('tracearr:sessions:session-1'); + expect(storedData).toBeDefined(); + expect(JSON.parse(storedData!).id).toBe('session-1'); + }); + + it('should invalidate dashboard stats atomically', async () => { + const session = createTestActiveSession('session-1'); + + await cache.addActiveSession(session); + + // Dashboard stats should be deleted as part of the pipeline + expect(redis.store.has('tracearr:stats:dashboard')).toBe(false); + }); + + it('should use Redis pipeline for atomicity', async () => { + const session = createTestActiveSession('session-1'); + + await cache.addActiveSession(session); + + // Verify multi() was called for pipeline + expect(redis.multi).toHaveBeenCalled(); + }); + + it('should not create duplicates when called twice with same session', async () => { + const session = createTestActiveSession('session-1'); + + await cache.addActiveSession(session); + await cache.addActiveSession(session); + + // SET should only have one entry (SADD is idempotent) + const ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).toHaveLength(1); + }); + }); + + describe('removeActiveSession (atomic)', () => { + it('should remove session from SET and delete session data atomically', async () => { + const session = createTestActiveSession('session-1'); + + // First add a session + await cache.addActiveSession(session); + + // Verify it exists + let ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).toContain('session-1'); + + // Now remove it + await cache.removeActiveSession('session-1'); + + // Verify it's gone from SET + ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).not.toContain('session-1'); + + // Verify session data is deleted + expect(redis.store.has('tracearr:sessions:session-1')).toBe(false); + }); + + it('should invalidate dashboard stats atomically', async () => { + const session = createTestActiveSession('session-1'); + await cache.addActiveSession(session); + + // Set some dashboard stats + redis.store.set('tracearr:stats:dashboard', JSON.stringify({ activeStreams: 1 })); + + await cache.removeActiveSession('session-1'); + + // Dashboard stats should be deleted + expect(redis.store.has('tracearr:stats:dashboard')).toBe(false); + }); + + it('should handle removing non-existent session gracefully', async () => { + // Should not throw + await expect(cache.removeActiveSession('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('getAllActiveSessions', () => { + it('should return empty array when no sessions exist', async () => { + const result = await cache.getAllActiveSessions(); + + expect(result).toEqual([]); + }); + + it('should return all active sessions', async () => { + const session1 = createTestActiveSession('session-1'); + const session2 = createTestActiveSession('session-2'); + + await cache.addActiveSession(session1); + await cache.addActiveSession(session2); + + const result = await cache.getAllActiveSessions(); + + expect(result).toHaveLength(2); + expect(result.map((s: any) => s.id).sort()).toEqual(['session-1', 'session-2']); + }); + + it('should clean up stale IDs (IDs without session data)', async () => { + // Manually add a stale ID to the SET (no corresponding data) + redis.sets.set('tracearr:sessions:active:ids', new Set(['stale-id', 'valid-id'])); + redis.store.set( + 'tracearr:sessions:valid-id', + JSON.stringify(createTestActiveSession('valid-id')) + ); + + const result = await cache.getAllActiveSessions(); + + // Should only return the valid session + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('valid-id'); + + // Stale ID should have been cleaned up + const ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).not.toContain('stale-id'); + }); + }); + + describe('updateActiveSession', () => { + it('should update session data without modifying SET membership', async () => { + const session = createTestActiveSession('session-1'); + await cache.addActiveSession(session); + + // Update the session + const updatedSession = { ...session, progressMs: 50000 }; + await cache.updateActiveSession(updatedSession); + + // Verify data was updated + const storedData = redis.store.get('tracearr:sessions:session-1'); + expect(JSON.parse(storedData!).progressMs).toBe(50000); + + // Verify SET still contains the ID + const ids = await redis.smembers('tracearr:sessions:active:ids'); + expect(ids).toContain('session-1'); + }); + }); + + describe('syncActiveSessions (full replacement)', () => { + it('should replace all sessions atomically', async () => { + // Add some initial sessions + await cache.addActiveSession(createTestActiveSession('old-1')); + await cache.addActiveSession(createTestActiveSession('old-2')); + + // Sync with new sessions + const newSessions = [ + createTestActiveSession('new-1'), + createTestActiveSession('new-2'), + createTestActiveSession('new-3'), + ]; + await cache.syncActiveSessions(newSessions); + + const result = await cache.getAllActiveSessions(); + + // Should only have new sessions + expect(result).toHaveLength(3); + const ids = result.map((s: any) => s.id).sort(); + expect(ids).toEqual(['new-1', 'new-2', 'new-3']); + }); + + it('should handle empty sync (clear all sessions)', async () => { + await cache.addActiveSession(createTestActiveSession('session-1')); + + await cache.syncActiveSessions([]); + + const result = await cache.getAllActiveSessions(); + expect(result).toHaveLength(0); + }); + }); + + describe('incrementalSyncActiveSessions', () => { + it('should add new sessions without affecting existing', async () => { + await cache.addActiveSession(createTestActiveSession('existing-1')); + + await cache.incrementalSyncActiveSessions( + [createTestActiveSession('new-1')], // new + [], // stopped + [] // updated + ); + + const result = await cache.getAllActiveSessions(); + expect(result).toHaveLength(2); + }); + + it('should remove stopped sessions', async () => { + await cache.addActiveSession(createTestActiveSession('session-1')); + await cache.addActiveSession(createTestActiveSession('session-2')); + + await cache.incrementalSyncActiveSessions( + [], // new + ['session-1'], // stopped + [] // updated + ); + + const result = await cache.getAllActiveSessions(); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('session-2'); + }); + + it('should update existing sessions', async () => { + const session = createTestActiveSession('session-1'); + await cache.addActiveSession(session); + + const updated = { ...session, progressMs: 99999 }; + await cache.incrementalSyncActiveSessions( + [], // new + [], // stopped + [updated] // updated + ); + + const result = await cache.getAllActiveSessions(); + expect(result[0]!.progressMs).toBe(99999); + }); + + it('should handle mixed operations atomically', async () => { + await cache.addActiveSession(createTestActiveSession('keep')); + await cache.addActiveSession(createTestActiveSession('remove')); + + const newSession = createTestActiveSession('add'); + const updatedSession = { ...createTestActiveSession('keep'), progressMs: 12345 }; + + await cache.incrementalSyncActiveSessions( + [newSession], // new + ['remove'], // stopped + [updatedSession] // updated + ); + + const result = await cache.getAllActiveSessions(); + expect(result).toHaveLength(2); + + const kept = result.find((s: any) => s.id === 'keep'); + expect(kept!.progressMs).toBe(12345); + + const added = result.find((s: any) => s.id === 'add'); + expect(added).toBeDefined(); + + const removed = result.find((s: any) => s.id === 'remove'); + expect(removed).toBeUndefined(); + }); + + it('should not fail when no changes', async () => { + await expect( + cache.incrementalSyncActiveSessions([], [], []) + ).resolves.not.toThrow(); + }); + }); + + describe('concurrent operations (race condition fix verification)', () => { + it('should handle concurrent add and remove on different sessions', async () => { + // This test verifies the fix for the original race condition + // Previously: read-modify-write would cause one operation to overwrite the other + // Now: SADD/SREM are atomic and don't interfere + + // Add initial sessions + await cache.addActiveSession(createTestActiveSession('session-1')); + await cache.addActiveSession(createTestActiveSession('session-2')); + + // Simulate concurrent operations (in real code these could interleave) + await Promise.all([ + cache.addActiveSession(createTestActiveSession('session-3')), + cache.removeActiveSession('session-1'), + ]); + + const result = await cache.getAllActiveSessions(); + const ids = result.map((s: any) => s.id).sort(); + + // session-1 should be removed + // session-2 should remain + // session-3 should be added + expect(ids).toEqual(['session-2', 'session-3']); + }); + + it('should handle concurrent removes on different sessions', async () => { + // Add sessions + await cache.addActiveSession(createTestActiveSession('session-1')); + await cache.addActiveSession(createTestActiveSession('session-2')); + await cache.addActiveSession(createTestActiveSession('session-3')); + + // Concurrent removes + await Promise.all([ + cache.removeActiveSession('session-1'), + cache.removeActiveSession('session-2'), + ]); + + const result = await cache.getAllActiveSessions(); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe('session-3'); + }); + }); +}); + +describe('PubSubService', () => { + let publisher: ReturnType; + let subscriber: ReturnType; + let pubsub: PubSubService; + + beforeEach(() => { + publisher = createMockRedis(); + subscriber = createMockRedis(); + pubsub = createPubSubService(publisher, subscriber); + }); + + describe('publish', () => { + it('should publish event with data to events channel', async () => { + const eventData = { userId: 'user-123', action: 'login' }; + + await pubsub.publish('user.login', eventData); + + expect(publisher.publish).toHaveBeenCalledWith( + 'tracearr:events', + expect.stringContaining('"event":"user.login"') + ); + }); + + it('should include timestamp in published message', async () => { + const before = Date.now(); + await pubsub.publish('test.event', { data: 'test' }); + const after = Date.now(); + + const publishCall = vi.mocked(publisher.publish).mock.calls[0]; + const message = JSON.parse(publishCall![1] as string); + + expect(message.timestamp).toBeGreaterThanOrEqual(before); + expect(message.timestamp).toBeLessThanOrEqual(after); + }); + + it('should stringify complex data structures', async () => { + const complexData = { + nested: { array: [1, 2, 3], obj: { key: 'value' } }, + number: 42, + boolean: true, + }; + + await pubsub.publish('complex.event', complexData); + + const publishCall = vi.mocked(publisher.publish).mock.calls[0]; + const message = JSON.parse(publishCall![1] as string); + + expect(message.data).toEqual(complexData); + }); + }); + + describe('subscribe', () => { + it('should subscribe to channel', async () => { + const callback = vi.fn(); + + await pubsub.subscribe('test-channel', callback); + + expect(subscriber.subscribe).toHaveBeenCalledWith('test-channel'); + }); + + it('should invoke callback when message received', async () => { + const callback = vi.fn(); + + await pubsub.subscribe('test-channel', callback); + + // Simulate incoming message + (subscriber as any)._simulateMessage('test-channel', '{"test": "data"}'); + + expect(callback).toHaveBeenCalledWith('{"test": "data"}'); + }); + + it('should route messages to correct callback', async () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + await pubsub.subscribe('channel-1', callback1); + await pubsub.subscribe('channel-2', callback2); + + (subscriber as any)._simulateMessage('channel-1', 'message-1'); + (subscriber as any)._simulateMessage('channel-2', 'message-2'); + + expect(callback1).toHaveBeenCalledWith('message-1'); + expect(callback2).toHaveBeenCalledWith('message-2'); + expect(callback1).not.toHaveBeenCalledWith('message-2'); + expect(callback2).not.toHaveBeenCalledWith('message-1'); + }); + + it('should not invoke callback for unsubscribed channel', async () => { + const callback = vi.fn(); + + await pubsub.subscribe('subscribed-channel', callback); + + (subscriber as any)._simulateMessage('other-channel', 'message'); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('unsubscribe', () => { + it('should unsubscribe from channel', async () => { + const callback = vi.fn(); + + await pubsub.subscribe('test-channel', callback); + await pubsub.unsubscribe('test-channel'); + + expect(subscriber.unsubscribe).toHaveBeenCalledWith('test-channel'); + }); + + it('should not invoke callback after unsubscribe', async () => { + const callback = vi.fn(); + + await pubsub.subscribe('test-channel', callback); + await pubsub.unsubscribe('test-channel'); + + (subscriber as any)._simulateMessage('test-channel', 'message'); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('getPubSubService', () => { + it('should return the created pubsub instance', () => { + const result = getPubSubService(); + + expect(result).toBe(pubsub); + }); + }); +}); diff --git a/apps/server/src/services/__tests__/geoip.test.ts b/apps/server/src/services/__tests__/geoip.test.ts new file mode 100644 index 0000000..779e1ce --- /dev/null +++ b/apps/server/src/services/__tests__/geoip.test.ts @@ -0,0 +1,458 @@ +/** + * GeoIP Service Tests + * + * Tests the ACTUAL GeoIPService class from geoip.ts: + * - isPrivateIP: IPv4 private range detection + * - isPrivateIPv6: IPv6 private range detection (via isPrivateIP) + * - calculateDistance: Haversine formula distance calculation + * - isImpossibleTravel: Impossible travel detection based on speed + * - lookup: GeoIP lookup with mocked reader + * + * These tests validate: + * - Private IP detection for all RFC 1918 ranges + * - IPv6 loopback, link-local, and unique local addresses + * - Distance calculation accuracy against known values + * - Impossible travel threshold detection + * - Graceful handling of missing coordinates + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +// Import ACTUAL production class and types - not local duplicates +import { GeoIPService, type GeoLocation } from '../geoip.js'; + +describe('GeoIPService', () => { + let service: GeoIPService; + + beforeEach(() => { + // Create fresh instance for each test + service = new GeoIPService(); + }); + + describe('isPrivateIP', () => { + describe('IPv4 private ranges', () => { + it('should detect 10.x.x.x range as private (Class A)', () => { + expect(service.isPrivateIP('10.0.0.0')).toBe(true); + expect(service.isPrivateIP('10.0.0.1')).toBe(true); + expect(service.isPrivateIP('10.255.255.255')).toBe(true); + expect(service.isPrivateIP('10.128.64.32')).toBe(true); + }); + + it('should detect 172.16-31.x.x range as private (Class B)', () => { + // Lower bound + expect(service.isPrivateIP('172.16.0.0')).toBe(true); + expect(service.isPrivateIP('172.16.0.1')).toBe(true); + + // Middle values + expect(service.isPrivateIP('172.20.100.50')).toBe(true); + expect(service.isPrivateIP('172.24.255.255')).toBe(true); + + // Upper bound + expect(service.isPrivateIP('172.31.255.255')).toBe(true); + expect(service.isPrivateIP('172.31.0.0')).toBe(true); + }); + + it('should NOT detect 172.x outside 16-31 as private', () => { + expect(service.isPrivateIP('172.15.255.255')).toBe(false); + expect(service.isPrivateIP('172.32.0.0')).toBe(false); + expect(service.isPrivateIP('172.0.0.1')).toBe(false); + }); + + it('should detect 192.168.x.x range as private (Class C)', () => { + expect(service.isPrivateIP('192.168.0.0')).toBe(true); + expect(service.isPrivateIP('192.168.0.1')).toBe(true); + expect(service.isPrivateIP('192.168.1.1')).toBe(true); + expect(service.isPrivateIP('192.168.255.255')).toBe(true); + }); + + it('should NOT detect other 192.x ranges as private', () => { + expect(service.isPrivateIP('192.167.0.1')).toBe(false); + expect(service.isPrivateIP('192.169.0.1')).toBe(false); + expect(service.isPrivateIP('192.0.0.1')).toBe(false); + }); + + it('should detect 127.x.x.x loopback as private', () => { + expect(service.isPrivateIP('127.0.0.1')).toBe(true); + expect(service.isPrivateIP('127.0.0.0')).toBe(true); + expect(service.isPrivateIP('127.255.255.255')).toBe(true); + expect(service.isPrivateIP('127.1.2.3')).toBe(true); + }); + + it('should detect 169.254.x.x link-local as private', () => { + expect(service.isPrivateIP('169.254.0.0')).toBe(true); + expect(service.isPrivateIP('169.254.0.1')).toBe(true); + expect(service.isPrivateIP('169.254.255.255')).toBe(true); + expect(service.isPrivateIP('169.254.128.64')).toBe(true); + }); + + it('should NOT detect 169.x outside link-local as private', () => { + expect(service.isPrivateIP('169.253.0.1')).toBe(false); + expect(service.isPrivateIP('169.255.0.1')).toBe(false); + }); + }); + + describe('IPv4 public addresses', () => { + it('should NOT detect public addresses as private', () => { + expect(service.isPrivateIP('8.8.8.8')).toBe(false); // Google DNS + expect(service.isPrivateIP('1.1.1.1')).toBe(false); // Cloudflare DNS + expect(service.isPrivateIP('208.67.222.222')).toBe(false); // OpenDNS + expect(service.isPrivateIP('74.125.224.72')).toBe(false); // Random public + expect(service.isPrivateIP('151.101.1.140')).toBe(false); // Reddit + }); + }); + + describe('IPv6 addresses', () => { + it('should detect ::1 loopback as private', () => { + expect(service.isPrivateIP('::1')).toBe(true); + }); + + it('should detect ::ffff:127.0.0.1 mapped loopback as private', () => { + expect(service.isPrivateIP('::ffff:127.0.0.1')).toBe(true); + }); + + it('should detect IPv4-mapped private addresses', () => { + expect(service.isPrivateIP('::ffff:10.0.0.1')).toBe(true); + expect(service.isPrivateIP('::ffff:192.168.1.1')).toBe(true); + expect(service.isPrivateIP('::ffff:172.16.0.1')).toBe(true); + }); + + it('should NOT detect IPv4-mapped public addresses as private', () => { + expect(service.isPrivateIP('::ffff:8.8.8.8')).toBe(false); + expect(service.isPrivateIP('::ffff:1.1.1.1')).toBe(false); + }); + + it('should detect fe80:: link-local as private', () => { + expect(service.isPrivateIP('fe80::')).toBe(true); + expect(service.isPrivateIP('fe80::1')).toBe(true); + expect(service.isPrivateIP('fe80::abcd:1234:5678:9abc')).toBe(true); + }); + + it('should detect fc00::/fd00:: unique local as private', () => { + expect(service.isPrivateIP('fc00::')).toBe(true); + expect(service.isPrivateIP('fc00::1')).toBe(true); + expect(service.isPrivateIP('fd00::')).toBe(true); + expect(service.isPrivateIP('fd12:3456:789a::1')).toBe(true); + }); + + it('should NOT detect global IPv6 addresses as private', () => { + expect(service.isPrivateIP('2001:4860:4860::8888')).toBe(false); // Google DNS + expect(service.isPrivateIP('2606:4700:4700::1111')).toBe(false); // Cloudflare + }); + }); + + describe('edge cases', () => { + it('should handle malformed IPv4 addresses', () => { + // These should fall through to IPv6 check and return false + expect(service.isPrivateIP('256.0.0.1')).toBe(false); + expect(service.isPrivateIP('-1.0.0.1')).toBe(false); + expect(service.isPrivateIP('10.0.0')).toBe(false); + expect(service.isPrivateIP('10.0.0.1.1')).toBe(false); + }); + + it('should handle non-IP strings gracefully', () => { + expect(service.isPrivateIP('')).toBe(false); + expect(service.isPrivateIP('not-an-ip')).toBe(false); + expect(service.isPrivateIP('abc.def.ghi.jkl')).toBe(false); + }); + }); + }); + + describe('calculateDistance', () => { + // Known distances for validation (approximate due to Earth not being perfect sphere) + const locations = { + newYork: { city: 'New York', region: null, country: 'USA', countryCode: 'US', lat: 40.7128, lon: -74.006 }, + losAngeles: { city: 'Los Angeles', region: null, country: 'USA', countryCode: 'US', lat: 34.0522, lon: -118.2437 }, + london: { city: 'London', region: null, country: 'UK', countryCode: 'GB', lat: 51.5074, lon: -0.1278 }, + tokyo: { city: 'Tokyo', region: null, country: 'Japan', countryCode: 'JP', lat: 35.6762, lon: 139.6503 }, + sydney: { city: 'Sydney', region: null, country: 'Australia', countryCode: 'AU', lat: -33.8688, lon: 151.2093 }, + nullLocation: { city: null, region: null, country: null, countryCode: null, lat: null, lon: null }, + partialNull: { city: 'Test', region: null, country: null, countryCode: null, lat: 40.0, lon: null }, + }; + + it('should calculate NYC to LA distance (~3940 km)', () => { + const distance = service.calculateDistance(locations.newYork, locations.losAngeles); + + expect(distance).not.toBeNull(); + // NYC to LA is approximately 3940 km + expect(distance).toBeGreaterThan(3900); + expect(distance).toBeLessThan(4000); + }); + + it('should calculate NYC to London distance (~5570 km)', () => { + const distance = service.calculateDistance(locations.newYork, locations.london); + + expect(distance).not.toBeNull(); + // NYC to London is approximately 5570 km + expect(distance).toBeGreaterThan(5500); + expect(distance).toBeLessThan(5650); + }); + + it('should calculate London to Tokyo distance (~9560 km)', () => { + const distance = service.calculateDistance(locations.london, locations.tokyo); + + expect(distance).not.toBeNull(); + // London to Tokyo is approximately 9560 km + expect(distance).toBeGreaterThan(9500); + expect(distance).toBeLessThan(9650); + }); + + it('should calculate Sydney to London distance (~16990 km)', () => { + const distance = service.calculateDistance(locations.sydney, locations.london); + + expect(distance).not.toBeNull(); + // Sydney to London is approximately 16990 km + expect(distance).toBeGreaterThan(16900); + expect(distance).toBeLessThan(17100); + }); + + it('should return 0 for same location', () => { + const distance = service.calculateDistance(locations.newYork, locations.newYork); + + expect(distance).toBe(0); + }); + + it('should return null when first location has null coordinates', () => { + const distance = service.calculateDistance(locations.nullLocation, locations.newYork); + + expect(distance).toBeNull(); + }); + + it('should return null when second location has null coordinates', () => { + const distance = service.calculateDistance(locations.newYork, locations.nullLocation); + + expect(distance).toBeNull(); + }); + + it('should return null when both locations have null coordinates', () => { + const distance = service.calculateDistance(locations.nullLocation, locations.nullLocation); + + expect(distance).toBeNull(); + }); + + it('should return null for partial null coordinates (lon is null)', () => { + const distance = service.calculateDistance(locations.partialNull, locations.newYork); + + expect(distance).toBeNull(); + }); + + it('should handle cross-hemisphere calculations', () => { + // New York (Northern) to Sydney (Southern) + const distance = service.calculateDistance(locations.newYork, locations.sydney); + + expect(distance).not.toBeNull(); + // Approximately 16000 km + expect(distance).toBeGreaterThan(15900); + expect(distance).toBeLessThan(16100); + }); + + it('should handle prime meridian crossing', () => { + // London to Tokyo crosses the prime meridian + const distance = service.calculateDistance(locations.london, locations.tokyo); + + expect(distance).not.toBeNull(); + expect(distance).toBeGreaterThan(0); + }); + + it('should be symmetric (a to b = b to a)', () => { + const distanceAB = service.calculateDistance(locations.newYork, locations.london); + const distanceBA = service.calculateDistance(locations.london, locations.newYork); + + expect(distanceAB).toBe(distanceBA); + }); + }); + + describe('isImpossibleTravel', () => { + const newYork: GeoLocation = { + city: 'New York', + region: null, + country: 'USA', + countryCode: 'US', + lat: 40.7128, + lon: -74.006, + }; + + const losAngeles: GeoLocation = { + city: 'Los Angeles', + region: null, + country: 'USA', + countryCode: 'US', + lat: 34.0522, + lon: -118.2437, + }; + + const london: GeoLocation = { + city: 'London', + region: null, + country: 'UK', + countryCode: 'GB', + lat: 51.5074, + lon: -0.1278, + }; + + const nullLocation: GeoLocation = { + city: null, + region: null, + country: null, + countryCode: null, + lat: null, + lon: null, + }; + + // NYC to LA is ~3940 km + // At 900 km/h (default), that's ~4.4 hours + + it('should detect impossible travel (NYC to LA in 1 hour)', () => { + const oneHourMs = 1 * 60 * 60 * 1000; + + const result = service.isImpossibleTravel(newYork, losAngeles, oneHourMs); + + // 3940 km in 1 hour = 3940 km/h >> 900 km/h + expect(result).toBe(true); + }); + + it('should allow possible travel (NYC to LA in 6 hours)', () => { + const sixHoursMs = 6 * 60 * 60 * 1000; + + const result = service.isImpossibleTravel(newYork, losAngeles, sixHoursMs); + + // 3940 km in 6 hours = ~657 km/h < 900 km/h + expect(result).toBe(false); + }); + + it('should detect impossible travel (NYC to London in 2 hours)', () => { + const twoHoursMs = 2 * 60 * 60 * 1000; + + const result = service.isImpossibleTravel(newYork, london, twoHoursMs); + + // 5570 km in 2 hours = 2785 km/h >> 900 km/h + expect(result).toBe(true); + }); + + it('should allow possible travel (NYC to London in 8 hours)', () => { + const eightHoursMs = 8 * 60 * 60 * 1000; + + const result = service.isImpossibleTravel(newYork, london, eightHoursMs); + + // 5570 km in 8 hours = ~696 km/h < 900 km/h + expect(result).toBe(false); + }); + + it('should return false when coordinates are missing', () => { + const oneHourMs = 1 * 60 * 60 * 1000; + + expect(service.isImpossibleTravel(nullLocation, newYork, oneHourMs)).toBe(false); + expect(service.isImpossibleTravel(newYork, nullLocation, oneHourMs)).toBe(false); + expect(service.isImpossibleTravel(nullLocation, nullLocation, oneHourMs)).toBe(false); + }); + + it('should detect impossible travel at zero time delta with non-zero distance', () => { + const result = service.isImpossibleTravel(newYork, losAngeles, 0); + + // Distance > 0, time = 0 means infinite speed required + expect(result).toBe(true); + }); + + it('should allow same location at zero time delta', () => { + const result = service.isImpossibleTravel(newYork, newYork, 0); + + // Distance = 0, time = 0, no movement needed + expect(result).toBe(false); + }); + + it('should detect impossible travel at negative time delta with distance', () => { + const negativeTimeMs = -1000; + + const result = service.isImpossibleTravel(newYork, losAngeles, negativeTimeMs); + + // Time is negative but distance > 0, should be impossible + expect(result).toBe(true); + }); + + it('should respect custom max speed parameter', () => { + // NYC to LA is ~3940 km + const fourHoursMs = 4 * 60 * 60 * 1000; + // Required speed would be ~985 km/h (3940 / 4) + + // Default max speed is 900 km/h - should be impossible + expect(service.isImpossibleTravel(newYork, losAngeles, fourHoursMs)).toBe(true); + + // With higher max speed (1000 km/h) - should be possible + expect(service.isImpossibleTravel(newYork, losAngeles, fourHoursMs, 1000)).toBe(false); + + // With lower max speed (500 km/h) - should be impossible + expect(service.isImpossibleTravel(newYork, losAngeles, fourHoursMs, 500)).toBe(true); + }); + + it('should handle very small distances and times', () => { + // Same city, small time delta + const nearbyLocation: GeoLocation = { + city: 'NYC Downtown', + region: null, + country: 'USA', + countryCode: 'US', + lat: 40.7128 + 0.001, // Very small offset (~111 meters) + lon: -74.006, + }; + + const fiveMinutesMs = 5 * 60 * 1000; + + const result = service.isImpossibleTravel(newYork, nearbyLocation, fiveMinutesMs); + + // ~111 meters in 5 minutes = trivially possible + expect(result).toBe(false); + }); + }); + + describe('initialization state', () => { + it('should not be initialized by default', () => { + expect(service.isInitialized()).toBe(false); + }); + + it('should not have database by default', () => { + expect(service.hasDatabase()).toBe(false); + }); + }); + + describe('lookup', () => { + it('should return Local location for private IPs (without database)', () => { + const result = service.lookup('192.168.1.1'); + + expect(result.city).toBe('Local'); + expect(result.country).toBe('Local Network'); + expect(result.lat).toBeNull(); + expect(result.lon).toBeNull(); + }); + + it('should return Local location for loopback', () => { + const result = service.lookup('127.0.0.1'); + + expect(result.city).toBe('Local'); + expect(result.country).toBe('Local Network'); + }); + + it('should return null location for public IPs when no database loaded', () => { + // No database initialized + const result = service.lookup('8.8.8.8'); + + expect(result.city).toBeNull(); + expect(result.country).toBeNull(); + expect(result.countryCode).toBeNull(); + expect(result.lat).toBeNull(); + expect(result.lon).toBeNull(); + }); + + it('should return Local for IPv6 loopback', () => { + const result = service.lookup('::1'); + + expect(result.city).toBe('Local'); + expect(result.country).toBe('Local Network'); + }); + + it('should return Local for IPv6 link-local', () => { + const result = service.lookup('fe80::1'); + + expect(result.city).toBe('Local'); + expect(result.country).toBe('Local Network'); + }); + }); +}); diff --git a/apps/server/src/services/__tests__/pushEncryption.test.ts b/apps/server/src/services/__tests__/pushEncryption.test.ts new file mode 100644 index 0000000..74017b7 --- /dev/null +++ b/apps/server/src/services/__tests__/pushEncryption.test.ts @@ -0,0 +1,353 @@ +/** + * Push Encryption Service Tests + * + * Tests the push notification payload encryption service: + * - AES-256-GCM encryption with proper IV/salt generation + * - PBKDF2 key derivation consistency + * - Payload handling (Unicode, size limits) + * - Encryption toggle based on device secret + * + * Uses a test-only decrypt function to verify encrypted payloads + * can be properly decrypted (simulating mobile client behavior). + */ + +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; +import type { EncryptedPushPayload } from '@tracearr/shared'; +import { + encryptPushPayload, + shouldEncryptPush, + pushEncryptionService, +} from '../pushEncryption.js'; + +// Constants matching the encryption service +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const PBKDF2_ITERATIONS = 100000; + +/** + * Test-only decrypt function that mirrors mobile client decryption + * Used to verify encrypted payloads can be properly decrypted + */ +function decryptPushPayload( + encrypted: EncryptedPushPayload, + deviceSecret: string +): Record { + const iv = Buffer.from(encrypted.iv, 'base64'); + const salt = Buffer.from(encrypted.salt, 'base64'); + const ciphertext = Buffer.from(encrypted.ct, 'base64'); + const authTag = Buffer.from(encrypted.tag, 'base64'); + + // Derive key using same PBKDF2 params + const key = pbkdf2Sync(deviceSecret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); + + // Create decipher + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + // Decrypt + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + return JSON.parse(decrypted.toString('utf8')); +} + +describe('pushEncryption', () => { + const testDeviceSecret = 'test-device-secret-12345678901234567890'; + + describe('encryptPushPayload', () => { + it('encrypts and decrypts payload correctly', () => { + const payload = { + type: 'session:started', + title: 'Breaking Bad', + username: 'walter', + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + + // Verify structure + expect(encrypted.v).toBe(1); + expect(encrypted.iv).toBeDefined(); + expect(encrypted.salt).toBeDefined(); + expect(encrypted.ct).toBeDefined(); + expect(encrypted.tag).toBeDefined(); + + // Verify decryption works + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + expect(decrypted).toEqual(payload); + }); + + it('produces different ciphertext for same plaintext (random IV)', () => { + const payload = { message: 'test notification' }; + + const encrypted1 = encryptPushPayload(payload, testDeviceSecret); + const encrypted2 = encryptPushPayload(payload, testDeviceSecret); + + // IVs should be different (random) + expect(encrypted1.iv).not.toBe(encrypted2.iv); + // Salts should be different (random) + expect(encrypted1.salt).not.toBe(encrypted2.salt); + // Ciphertext should be different due to different IV/salt + expect(encrypted1.ct).not.toBe(encrypted2.ct); + // Auth tags should be different + expect(encrypted1.tag).not.toBe(encrypted2.tag); + + // But both should decrypt to same payload + expect(decryptPushPayload(encrypted1, testDeviceSecret)).toEqual(payload); + expect(decryptPushPayload(encrypted2, testDeviceSecret)).toEqual(payload); + }); + + it('fails decryption with wrong key', () => { + const payload = { secret: 'sensitive data' }; + const encrypted = encryptPushPayload(payload, testDeviceSecret); + + // Attempt decryption with wrong secret + expect(() => { + decryptPushPayload(encrypted, 'wrong-device-secret'); + }).toThrow(); // GCM auth will fail + }); + + it('fails decryption with tampered ciphertext', () => { + const payload = { important: 'data' }; + const encrypted = encryptPushPayload(payload, testDeviceSecret); + + // Tamper with ciphertext + const ctBuffer = Buffer.from(encrypted.ct, 'base64'); + ctBuffer[0] = (ctBuffer[0] ?? 0) ^ 0xff; // Flip bits + const tampered: EncryptedPushPayload = { + ...encrypted, + ct: ctBuffer.toString('base64'), + }; + + // Attempt decryption should fail auth tag verification + expect(() => { + decryptPushPayload(tampered, testDeviceSecret); + }).toThrow(); + }); + + it('fails decryption with tampered auth tag', () => { + const payload = { critical: 'information' }; + const encrypted = encryptPushPayload(payload, testDeviceSecret); + + // Tamper with auth tag + const tagBuffer = Buffer.from(encrypted.tag, 'base64'); + tagBuffer[0] = (tagBuffer[0] ?? 0) ^ 0xff; + const tampered: EncryptedPushPayload = { + ...encrypted, + tag: tagBuffer.toString('base64'), + }; + + expect(() => { + decryptPushPayload(tampered, testDeviceSecret); + }).toThrow(); + }); + }); + + describe('key derivation', () => { + it('generates valid 256-bit keys (32 bytes)', () => { + const payload = { test: true }; + const encrypted = encryptPushPayload(payload, testDeviceSecret); + + // Salt should be 16 bytes (128 bits) + const salt = Buffer.from(encrypted.salt, 'base64'); + expect(salt.length).toBe(16); + + // IV should be 12 bytes (96 bits) + const iv = Buffer.from(encrypted.iv, 'base64'); + expect(iv.length).toBe(12); + + // Auth tag should be 16 bytes (128 bits) + const tag = Buffer.from(encrypted.tag, 'base64'); + expect(tag.length).toBe(16); + }); + + it('derives consistent key from device ID and salt', () => { + // Same secret + same salt = same key (deterministic PBKDF2) + const salt = Buffer.from('0123456789abcdef'); // 16 bytes + const key1 = pbkdf2Sync(testDeviceSecret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); + const key2 = pbkdf2Sync(testDeviceSecret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); + + expect(key1.equals(key2)).toBe(true); + }); + + it('derives different keys for different device secrets', () => { + const salt = Buffer.from('0123456789abcdef'); + const key1 = pbkdf2Sync('device-secret-1', salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); + const key2 = pbkdf2Sync('device-secret-2', salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); + + expect(key1.equals(key2)).toBe(false); + }); + }); + + describe('payload handling', () => { + it('handles Unicode characters correctly', () => { + const payload = { + title: '日本語タイトル', + emoji: '🎬🍿', + chinese: '中文内容', + arabic: 'محتوى عربي', + mixed: 'Hello 世界 🌍', + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + + it('handles nested objects', () => { + const payload = { + session: { + id: 'abc123', + user: { + name: 'testuser', + settings: { + notifications: true, + }, + }, + }, + metadata: { + timestamp: 1234567890, + }, + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + + it('handles arrays in payload', () => { + const payload = { + users: ['alice', 'bob', 'charlie'], + counts: [1, 2, 3, 4, 5], + nested: [{ id: 1 }, { id: 2 }], + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + + it('handles empty payload', () => { + const payload = {}; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + + it('handles payload with null values', () => { + const payload = { + title: 'Test', + subtitle: null, + metadata: null, + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + + it('handles large payload within typical size limits', () => { + // Create a payload around 3KB (under typical 4KB push limit) + const largeContent = 'x'.repeat(2500); + const payload = { + type: 'notification', + content: largeContent, + timestamp: Date.now(), + }; + + const encrypted = encryptPushPayload(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + expect((decrypted as { content: string }).content.length).toBe(2500); + }); + }); + + describe('shouldEncryptPush', () => { + it('returns true for valid device secret', () => { + expect(shouldEncryptPush('valid-secret')).toBe(true); + expect(shouldEncryptPush('a')).toBe(true); + expect(shouldEncryptPush(testDeviceSecret)).toBe(true); + }); + + it('returns false for null device secret', () => { + expect(shouldEncryptPush(null)).toBe(false); + }); + + it('returns false for empty string device secret', () => { + expect(shouldEncryptPush('')).toBe(false); + }); + }); + + describe('PushEncryptionService', () => { + describe('encryptIfEnabled', () => { + it('returns encrypted payload when device secret is provided', () => { + const payload = { type: 'test' }; + + const result = pushEncryptionService.encryptIfEnabled(payload, testDeviceSecret); + + // Should be encrypted + expect(result).toHaveProperty('v', 1); + expect(result).toHaveProperty('iv'); + expect(result).toHaveProperty('salt'); + expect(result).toHaveProperty('ct'); + expect(result).toHaveProperty('tag'); + + // Verify it decrypts correctly + const decrypted = decryptPushPayload(result as EncryptedPushPayload, testDeviceSecret); + expect(decrypted).toEqual(payload); + }); + + it('returns unencrypted payload when device secret is null', () => { + const payload = { type: 'test', data: 'value' }; + + const result = pushEncryptionService.encryptIfEnabled(payload, null); + + // Should be the original payload unchanged + expect(result).toEqual(payload); + expect(result).not.toHaveProperty('v'); + expect(result).not.toHaveProperty('ct'); + }); + + it('returns unencrypted payload when device secret is empty string', () => { + const payload = { message: 'hello' }; + + const result = pushEncryptionService.encryptIfEnabled(payload, ''); + + expect(result).toEqual(payload); + }); + }); + + describe('encrypt', () => { + it('always encrypts the payload', () => { + const payload = { type: 'notification' }; + + const result = pushEncryptionService.encrypt(payload, testDeviceSecret); + + expect(result.v).toBe(1); + expect(result.iv).toBeDefined(); + expect(result.salt).toBeDefined(); + expect(result.ct).toBeDefined(); + expect(result.tag).toBeDefined(); + }); + + it('produces decryptable output', () => { + const payload = { + type: 'violation:new', + severity: 'high', + user: 'suspicious_user', + }; + + const encrypted = pushEncryptionService.encrypt(payload, testDeviceSecret); + const decrypted = decryptPushPayload(encrypted, testDeviceSecret); + + expect(decrypted).toEqual(payload); + }); + }); + }); +}); diff --git a/apps/server/src/services/__tests__/pushRateLimiter.test.ts b/apps/server/src/services/__tests__/pushRateLimiter.test.ts new file mode 100644 index 0000000..fe8cd71 --- /dev/null +++ b/apps/server/src/services/__tests__/pushRateLimiter.test.ts @@ -0,0 +1,320 @@ +/** + * Push Rate Limiter Service Tests + * + * Tests the Redis-based rate limiting for push notifications: + * - Per-minute and per-hour sliding window limits + * - Atomic check-and-increment operations via Lua script + * - Status queries without recording + * - Rate limit reset functionality + * + * Uses a mock Redis that simulates Lua script execution. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Redis } from 'ioredis'; +import { + PushRateLimiter, + initPushRateLimiter, + getPushRateLimiter, + type RateLimitPrefs, +} from '../pushRateLimiter.js'; + +// Default prefs for testing +const DEFAULT_PREFS: RateLimitPrefs = { + maxPerMinute: 5, + maxPerHour: 30, +}; + +// Mock Redis that simulates rate limiting behavior +function createMockRedis(): Redis & { + store: Map; + ttls: Map; +} { + const store = new Map(); + const ttls = new Map(); + + return { + store, + ttls, + + // Simulates the Lua script behavior for rate limiting + eval: vi.fn( + async ( + _script: string, + _numKeys: number, + minuteKey: string, + hourKey: string, + maxPerMinute: string, + maxPerHour: string + ) => { + const maxMin = parseInt(maxPerMinute, 10); + const maxHr = parseInt(maxPerHour, 10); + + // Get current counts + let minuteCount = parseInt(store.get(minuteKey) ?? '0', 10); + let hourCount = parseInt(store.get(hourKey) ?? '0', 10); + + // Get TTLs + const minuteTTL = ttls.get(minuteKey) ?? -2; + const hourTTL = ttls.get(hourKey) ?? -2; + + // Check minute limit first + if (minuteCount >= maxMin) { + return [0, minuteCount, hourCount, minuteTTL, hourTTL, 1]; + } + + // Check hour limit + if (hourCount >= maxHr) { + return [0, minuteCount, hourCount, minuteTTL, hourTTL, 2]; + } + + // Increment counters + minuteCount += 1; + hourCount += 1; + store.set(minuteKey, minuteCount.toString()); + store.set(hourKey, hourCount.toString()); + + // Set TTLs if not already set + if (!ttls.has(minuteKey)) { + ttls.set(minuteKey, 60); + } + if (!ttls.has(hourKey)) { + ttls.set(hourKey, 3600); + } + + return [1, minuteCount, hourCount, ttls.get(minuteKey)!, ttls.get(hourKey)!, 0]; + } + ), + + get: vi.fn(async (key: string) => store.get(key) ?? null), + + ttl: vi.fn(async (key: string) => ttls.get(key) ?? -2), + + del: vi.fn(async (...keys: string[]) => { + let count = 0; + for (const key of keys) { + if (store.delete(key)) count++; + ttls.delete(key); + } + return count; + }), + } as unknown as Redis & { + store: Map; + ttls: Map; + }; +} + +describe('PushRateLimiter', () => { + let mockRedis: ReturnType; + let rateLimiter: PushRateLimiter; + + beforeEach(() => { + mockRedis = createMockRedis(); + rateLimiter = new PushRateLimiter(mockRedis); + }); + + describe('checkAndRecord', () => { + it('allows first notification', async () => { + const result = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + expect(result.allowed).toBe(true); + expect(result.remainingMinute).toBe(4); // 5 - 1 + expect(result.remainingHour).toBe(29); // 30 - 1 + expect(result.exceededLimit).toBeUndefined(); + }); + + it('tracks remaining counts correctly', async () => { + // Send 3 notifications + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + const result = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + expect(result.allowed).toBe(true); + expect(result.remainingMinute).toBe(2); // 5 - 3 + expect(result.remainingHour).toBe(27); // 30 - 3 + }); + + it('blocks when minute limit reached', async () => { + // Exhaust minute limit + for (let i = 0; i < 5; i++) { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + } + + // 6th should be blocked + const result = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + expect(result.allowed).toBe(false); + expect(result.remainingMinute).toBe(0); + expect(result.exceededLimit).toBe('minute'); + }); + + it('blocks when hour limit reached', async () => { + // Set up so minute resets but hour is at limit + // Simulate low minute limit, high hour limit that's already reached + const prefs: RateLimitPrefs = { maxPerMinute: 100, maxPerHour: 3 }; + + // Exhaust hour limit + await rateLimiter.checkAndRecord('session-1', prefs); + await rateLimiter.checkAndRecord('session-1', prefs); + await rateLimiter.checkAndRecord('session-1', prefs); + + // 4th should be blocked by hour limit + const result = await rateLimiter.checkAndRecord('session-1', prefs); + + expect(result.allowed).toBe(false); + expect(result.remainingHour).toBe(0); + expect(result.exceededLimit).toBe('hour'); + }); + + it('provides reset times in result', async () => { + const result = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + expect(result.resetMinuteIn).toBe(60); + expect(result.resetHourIn).toBe(3600); + }); + + it('isolates rate limits between sessions', async () => { + // Exhaust minute limit for session 1 + for (let i = 0; i < 5; i++) { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + } + + // Session 2 should still be allowed + const result = await rateLimiter.checkAndRecord('session-2', DEFAULT_PREFS); + + expect(result.allowed).toBe(true); + expect(result.remainingMinute).toBe(4); + }); + + it('handles different rate limit preferences', async () => { + const strictPrefs: RateLimitPrefs = { maxPerMinute: 2, maxPerHour: 10 }; + + await rateLimiter.checkAndRecord('session-1', strictPrefs); + const result = await rateLimiter.checkAndRecord('session-1', strictPrefs); + + expect(result.allowed).toBe(true); + expect(result.remainingMinute).toBe(0); // 2 - 2 + + // 3rd should be blocked + const blocked = await rateLimiter.checkAndRecord('session-1', strictPrefs); + expect(blocked.allowed).toBe(false); + }); + }); + + describe('getStatus', () => { + it('returns status without recording', async () => { + // Record one notification first + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + // Get status should not increment + const status1 = await rateLimiter.getStatus('session-1', DEFAULT_PREFS); + const status2 = await rateLimiter.getStatus('session-1', DEFAULT_PREFS); + + expect(status1.remainingMinute).toBe(4); + expect(status2.remainingMinute).toBe(4); // Same - not incremented + }); + + it('shows correct remaining counts', async () => { + // Record 3 notifications + for (let i = 0; i < 3; i++) { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + } + + const status = await rateLimiter.getStatus('session-1', DEFAULT_PREFS); + + expect(status.remainingMinute).toBe(2); + expect(status.remainingHour).toBe(27); + }); + + it('shows correct TTL values', async () => { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + + const status = await rateLimiter.getStatus('session-1', DEFAULT_PREFS); + + expect(status.resetMinuteIn).toBe(60); + expect(status.resetHourIn).toBe(3600); + }); + + it('returns default TTLs for new sessions', async () => { + const status = await rateLimiter.getStatus('new-session', DEFAULT_PREFS); + + // No keys exist, so should return full limits + expect(status.remainingMinute).toBe(5); + expect(status.remainingHour).toBe(30); + expect(status.resetMinuteIn).toBe(60); + expect(status.resetHourIn).toBe(3600); + }); + }); + + describe('reset', () => { + it('clears rate limit counters', async () => { + // Exhaust limits + for (let i = 0; i < 5; i++) { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + } + + // Should be blocked + const blocked = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + expect(blocked.allowed).toBe(false); + + // Reset + await rateLimiter.reset('session-1'); + + // Should be allowed again + const afterReset = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + expect(afterReset.allowed).toBe(true); + expect(afterReset.remainingMinute).toBe(4); + }); + + it('only resets specified session', async () => { + // Use up limits for both sessions + for (let i = 0; i < 5; i++) { + await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + await rateLimiter.checkAndRecord('session-2', DEFAULT_PREFS); + } + + // Reset only session 1 + await rateLimiter.reset('session-1'); + + // Session 1 should be allowed, session 2 still blocked + const result1 = await rateLimiter.checkAndRecord('session-1', DEFAULT_PREFS); + const result2 = await rateLimiter.checkAndRecord('session-2', DEFAULT_PREFS); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(false); + }); + }); +}); + +describe('module initialization', () => { + beforeEach(() => { + // Reset module state by re-importing + vi.resetModules(); + }); + + it('getPushRateLimiter returns null before initialization', async () => { + // Fresh import to reset module state + const { getPushRateLimiter: getFromFresh } = await import('../pushRateLimiter.js'); + + // Note: Due to module caching this may return the previously initialized instance + // In a real test environment, we'd need proper module isolation + const result = getFromFresh(); + // The instance might exist from previous tests, so we just verify it's defined behavior + expect(result === null || result instanceof PushRateLimiter).toBe(true); + }); + + it('initPushRateLimiter creates and returns instance', () => { + const mockRedis = createMockRedis(); + const instance = initPushRateLimiter(mockRedis); + + expect(instance).toBeInstanceOf(PushRateLimiter); + }); + + it('getPushRateLimiter returns instance after initialization', () => { + const mockRedis = createMockRedis(); + initPushRateLimiter(mockRedis); + + const instance = getPushRateLimiter(); + expect(instance).toBeInstanceOf(PushRateLimiter); + }); +}); diff --git a/apps/server/src/services/__tests__/quietHours.test.ts b/apps/server/src/services/__tests__/quietHours.test.ts new file mode 100644 index 0000000..b45243a --- /dev/null +++ b/apps/server/src/services/__tests__/quietHours.test.ts @@ -0,0 +1,306 @@ +/** + * Quiet Hours Service Tests + * + * Tests the quiet hours notification suppression: + * - Timezone-aware time comparison + * - Overnight quiet hour ranges + * - Severity-based bypass + * - Event type handling + * + * Uses vi.setSystemTime() to mock the current time. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + QuietHoursService, + quietHoursService, + type QuietHoursPrefs, +} from '../quietHours.js'; + +// Default prefs for testing +const DEFAULT_PREFS: QuietHoursPrefs = { + quietHoursEnabled: true, + quietHoursStart: '23:00', + quietHoursEnd: '07:00', + quietHoursTimezone: 'UTC', + quietHoursOverrideCritical: true, +}; + +describe('QuietHoursService', () => { + let service: QuietHoursService; + + beforeEach(() => { + service = new QuietHoursService(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('isQuietTime', () => { + it('returns false when quiet hours disabled', () => { + const prefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursEnabled: false, + }; + + // Set time to 02:00 UTC (would be in quiet hours if enabled) + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.isQuietTime(prefs)).toBe(false); + }); + + it('returns false when start time not set', () => { + const prefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursStart: null, + }; + + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.isQuietTime(prefs)).toBe(false); + }); + + it('returns false when end time not set', () => { + const prefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursEnd: null, + }; + + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.isQuietTime(prefs)).toBe(false); + }); + + describe('overnight quiet hours (23:00 - 07:00)', () => { + it('returns true when time is after midnight but before end', () => { + vi.setSystemTime(new Date('2025-01-15T03:30:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(true); + }); + + it('returns true when time is at start of quiet hours', () => { + vi.setSystemTime(new Date('2025-01-15T23:00:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(true); + }); + + it('returns true when time is at end of quiet hours', () => { + vi.setSystemTime(new Date('2025-01-15T07:00:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(true); + }); + + it('returns true when time is before midnight after start', () => { + vi.setSystemTime(new Date('2025-01-15T23:45:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(true); + }); + + it('returns false when time is before start', () => { + vi.setSystemTime(new Date('2025-01-15T22:00:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(false); + }); + + it('returns false when time is after end', () => { + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(false); + }); + + it('returns false at noon', () => { + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + + expect(service.isQuietTime(DEFAULT_PREFS)).toBe(false); + }); + }); + + describe('same-day quiet hours (01:00 - 06:00)', () => { + const sameDayPrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursStart: '01:00', + quietHoursEnd: '06:00', + }; + + it('returns true when time is within range', () => { + vi.setSystemTime(new Date('2025-01-15T03:00:00Z')); + + expect(service.isQuietTime(sameDayPrefs)).toBe(true); + }); + + it('returns true at start time', () => { + vi.setSystemTime(new Date('2025-01-15T01:00:00Z')); + + expect(service.isQuietTime(sameDayPrefs)).toBe(true); + }); + + it('returns true at end time', () => { + vi.setSystemTime(new Date('2025-01-15T06:00:00Z')); + + expect(service.isQuietTime(sameDayPrefs)).toBe(true); + }); + + it('returns false when before range', () => { + vi.setSystemTime(new Date('2025-01-15T00:30:00Z')); + + expect(service.isQuietTime(sameDayPrefs)).toBe(false); + }); + + it('returns false when after range', () => { + vi.setSystemTime(new Date('2025-01-15T08:00:00Z')); + + expect(service.isQuietTime(sameDayPrefs)).toBe(false); + }); + }); + + describe('timezone handling', () => { + it('handles America/New_York timezone correctly', () => { + // When it's 03:00 UTC, it's 22:00 EST (previous day) in winter + // In winter (January), EST is UTC-5 + const eastPrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursTimezone: 'America/New_York', + quietHoursStart: '22:00', // 10 PM EST + quietHoursEnd: '06:00', // 6 AM EST + }; + + // Set to 03:00 UTC = 22:00 EST (winter, UTC-5) + vi.setSystemTime(new Date('2025-01-15T03:00:00Z')); + + expect(service.isQuietTime(eastPrefs)).toBe(true); + }); + + it('handles Europe/London timezone correctly', () => { + // In January, London is UTC+0 (no DST) + const londonPrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursTimezone: 'Europe/London', + }; + + // 02:00 UTC = 02:00 London time + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.isQuietTime(londonPrefs)).toBe(true); + }); + + it('falls back to UTC on invalid timezone', () => { + const invalidPrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursTimezone: 'Invalid/Timezone', + }; + + // 02:00 UTC should be in quiet hours (23:00-07:00 UTC) + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + // Should not throw and should use UTC + expect(service.isQuietTime(invalidPrefs)).toBe(true); + }); + }); + }); + + describe('shouldSend', () => { + it('returns true when not in quiet hours (any severity)', () => { + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); // Noon UTC + + expect(service.shouldSend(DEFAULT_PREFS, 'low')).toBe(true); + expect(service.shouldSend(DEFAULT_PREFS, 'warning')).toBe(true); + expect(service.shouldSend(DEFAULT_PREFS, 'high')).toBe(true); + }); + + it('returns false for low severity during quiet hours', () => { + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); // 2 AM UTC + + expect(service.shouldSend(DEFAULT_PREFS, 'low')).toBe(false); + }); + + it('returns false for warning severity during quiet hours', () => { + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.shouldSend(DEFAULT_PREFS, 'warning')).toBe(false); + }); + + it('returns true for high severity during quiet hours when override enabled', () => { + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.shouldSend(DEFAULT_PREFS, 'high')).toBe(true); + }); + + it('returns false for high severity during quiet hours when override disabled', () => { + const noOverridePrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursOverrideCritical: false, + }; + + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.shouldSend(noOverridePrefs, 'high')).toBe(false); + }); + + it('returns true when quiet hours disabled (all severities)', () => { + const disabledPrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursEnabled: false, + }; + + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); + + expect(service.shouldSend(disabledPrefs, 'low')).toBe(true); + expect(service.shouldSend(disabledPrefs, 'warning')).toBe(true); + expect(service.shouldSend(disabledPrefs, 'high')).toBe(true); + }); + }); + + describe('shouldSendEvent', () => { + describe('during quiet hours', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2025-01-15T02:00:00Z')); // 2 AM UTC + }); + + it('treats server_down as high severity (allows with override)', () => { + expect(service.shouldSendEvent(DEFAULT_PREFS, 'server_down')).toBe(true); + }); + + it('treats server_down as high severity (blocks without override)', () => { + const noOverridePrefs: QuietHoursPrefs = { + ...DEFAULT_PREFS, + quietHoursOverrideCritical: false, + }; + + expect(service.shouldSendEvent(noOverridePrefs, 'server_down')).toBe(false); + }); + + it('treats session_started as low severity (blocks)', () => { + expect(service.shouldSendEvent(DEFAULT_PREFS, 'session_started')).toBe(false); + }); + + it('treats session_stopped as low severity (blocks)', () => { + expect(service.shouldSendEvent(DEFAULT_PREFS, 'session_stopped')).toBe(false); + }); + + it('treats server_up as low severity (blocks)', () => { + expect(service.shouldSendEvent(DEFAULT_PREFS, 'server_up')).toBe(false); + }); + }); + + describe('outside quiet hours', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); // Noon UTC + }); + + it('allows all event types', () => { + expect(service.shouldSendEvent(DEFAULT_PREFS, 'session_started')).toBe(true); + expect(service.shouldSendEvent(DEFAULT_PREFS, 'session_stopped')).toBe(true); + expect(service.shouldSendEvent(DEFAULT_PREFS, 'server_down')).toBe(true); + expect(service.shouldSendEvent(DEFAULT_PREFS, 'server_up')).toBe(true); + }); + }); + }); + + describe('singleton instance', () => { + it('quietHoursService is an instance of QuietHoursService', () => { + expect(quietHoursService).toBeInstanceOf(QuietHoursService); + }); + }); +}); diff --git a/apps/server/src/services/__tests__/rules.test.ts b/apps/server/src/services/__tests__/rules.test.ts new file mode 100644 index 0000000..6066fb0 --- /dev/null +++ b/apps/server/src/services/__tests__/rules.test.ts @@ -0,0 +1,1209 @@ +/** + * RuleEngine unit tests + * + * Tests all 5 rule types: + * - impossible_travel: Detects physically impossible location changes + * - simultaneous_locations: Detects same user streaming from distant locations simultaneously + * - device_velocity: Detects too many unique IPs in a time window + * - concurrent_streams: Detects exceeding stream limits + * - geo_restriction: Detects streams from blocked countries + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { RuleEngine } from '../rules.js'; +import { + createMockSession, + createMockRule, + createSessionsWithDifferentIps, + TEST_LOCATIONS, + calculateDistanceKm, +} from '../../test/fixtures.js'; + +describe('RuleEngine', () => { + let ruleEngine: RuleEngine; + + beforeEach(() => { + ruleEngine = new RuleEngine(); + }); + + describe('evaluateSession', () => { + it('should return empty array when no rules are active', async () => { + const session = createMockSession(); + const results = await ruleEngine.evaluateSession(session, [], []); + expect(results).toEqual([]); + }); + + it('should skip rules that do not apply to the user', async () => { + const serverUserId = 'user-123'; + const otherServerUserId = 'user-456'; + const session = createMockSession({ serverUserId }); + + // Rule applies only to a different user + const rule = createMockRule('concurrent_streams', { + serverUserId: otherServerUserId, + params: { maxStreams: 1 }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toEqual([]); + }); + + it('should apply global rules (serverUserId = null) to all users', async () => { + const serverUserId = 'user-123'; + const session = createMockSession({ serverUserId, state: 'playing' }); + + // Global rule (serverUserId = null) + const rule = createMockRule('concurrent_streams', { + serverUserId: null, + params: { maxStreams: 0 }, // Any stream violates + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + }); + + it('should apply user-specific rules to matching users', async () => { + const serverUserId = 'user-123'; + const session = createMockSession({ serverUserId, state: 'playing' }); + + // User-specific rule + const rule = createMockRule('concurrent_streams', { + serverUserId, + params: { maxStreams: 0 }, // Any stream violates + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + }); + + it('should return multiple violations if multiple rules trigger', async () => { + const serverUserId = 'user-123'; + const session = createMockSession({ + serverUserId, + state: 'playing', + geoCountry: 'CN', + }); + + const rules = [ + createMockRule('concurrent_streams', { + params: { maxStreams: 0 }, + }), + createMockRule('geo_restriction', { + params: { blockedCountries: ['CN'] }, + }), + ]; + + const results = await ruleEngine.evaluateSession(session, rules, []); + expect(results).toHaveLength(2); + }); + }); + + describe('impossible_travel', () => { + const serverUserId = 'user-123'; + + it('should not violate when speed is within limit', async () => { + // NYC to LA is ~3,944 km + // If 10 hours passed, speed = 394.4 km/h (within 500 km/h limit) + const previousSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + startedAt: new Date(Date.now() - 10 * 60 * 60 * 1000), // 10 hours ago + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.losAngeles.lat, + geoLon: TEST_LOCATIONS.losAngeles.lon, + startedAt: new Date(), + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should violate when speed exceeds limit', async () => { + // NYC to London is ~5,570 km + // If 2 hours passed, speed = 2,785 km/h (exceeds 500 km/h limit) + const previousSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.london.lat, + geoLon: TEST_LOCATIONS.london.lon, + startedAt: new Date(), + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + expect(results[0]!.severity).toBe('high'); + expect(results[0]!.data).toMatchObject({ + previousLocation: { + lat: TEST_LOCATIONS.newYork.lat, + lon: TEST_LOCATIONS.newYork.lon, + }, + currentLocation: { + lat: TEST_LOCATIONS.london.lat, + lon: TEST_LOCATIONS.london.lon, + }, + maxAllowedSpeed: 500, + }); + expect(results[0]!.data.calculatedSpeed).toBeGreaterThan(500); + }); + + it('should not violate when geo data is missing on current session', async () => { + const previousSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: null, + geoLon: null, + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should not violate when geo data is missing on previous session', async () => { + const previousSession = createMockSession({ + serverUserId, + geoLat: null, + geoLon: null, + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.london.lat, + geoLon: TEST_LOCATIONS.london.lon, + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should not violate when time difference is zero or negative', async () => { + const now = new Date(); + const previousSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + startedAt: now, + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.london.lat, + geoLon: TEST_LOCATIONS.london.lon, + startedAt: now, // Same time + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should check all recent sessions and find any violation', async () => { + // First session: Old, valid travel + const oldSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + startedAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago + }); + + // Second session: Recent, impossible travel from previous + const recentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.losAngeles.lat, + geoLon: TEST_LOCATIONS.losAngeles.lon, + startedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago + }); + + // Current session: Tokyo (impossible from LA in 30 min) + const currentSession = createMockSession({ + serverUserId, + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + startedAt: new Date(), + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [oldSession, recentSession] + ); + + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + }); + + it('should calculate distance correctly between known points', () => { + // NYC to LA is approximately 3,944 km + const distance = calculateDistanceKm( + TEST_LOCATIONS.newYork.lat, + TEST_LOCATIONS.newYork.lon, + TEST_LOCATIONS.losAngeles.lat, + TEST_LOCATIONS.losAngeles.lon + ); + expect(distance).toBeGreaterThan(3900); + expect(distance).toBeLessThan(4000); + }); + }); + + describe('simultaneous_locations', () => { + const serverUserId = 'user-123'; + + it('should not violate when distance is within limit', async () => { + // Two locations very close together (same city) + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat + 0.01, // Very close + geoLon: TEST_LOCATIONS.newYork.lon + 0.01, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should violate when distance exceeds limit', async () => { + // NYC to LA (~3,944 km) + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.losAngeles.lat, + geoLon: TEST_LOCATIONS.losAngeles.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + expect(results[0]!.severity).toBe('warning'); + expect(results[0]!.data).toMatchObject({ + minRequiredDistance: 100, + }); + expect(results[0]!.data.distance).toBeGreaterThan(100); + }); + + it('should ignore non-playing sessions', async () => { + // Paused session should be ignored + const pausedSession = createMockSession({ + serverUserId, + state: 'paused', + geoLat: TEST_LOCATIONS.london.lat, + geoLon: TEST_LOCATIONS.london.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [pausedSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should ignore stopped sessions', async () => { + const stoppedSession = createMockSession({ + serverUserId, + state: 'stopped', + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [stoppedSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should not violate when geo data is missing', async () => { + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: null, + geoLon: null, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should ignore sessions from different users', async () => { + const otherUserSession = createMockSession({ + serverUserId: 'other-user', + state: 'playing', + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [otherUserSession] + ); + + expect(results).toHaveLength(0); + }); + + it('should exclude sessions from the same device (likely stale session data)', async () => { + const sharedDeviceId = 'device-shared-123'; + + // Session from same device at different location (stale data) + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: sharedDeviceId, + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: sharedDeviceId, // Same device + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + // Should not violate - same device can't be in two places + expect(results).toHaveLength(0); + }); + + it('should still violate when different devices are in different locations', async () => { + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: 'device-123', + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: 'device-456', // Different device + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + // Should violate - different devices in distant locations + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + }); + + it('should not exclude when deviceId is null on either session', async () => { + const activeSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: null, // No device ID + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }); + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: 'device-123', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [activeSession] + ); + + // Should violate - can't determine if same device when deviceId is null + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + }); + + it('should include relatedSessionIds in violation data', async () => { + const activeSessions = [ + createMockSession({ + id: 'session-la', + serverUserId, + state: 'playing', + deviceId: 'device-1', + geoLat: TEST_LOCATIONS.losAngeles.lat, + geoLon: TEST_LOCATIONS.losAngeles.lon, + }), + createMockSession({ + id: 'session-tokyo', + serverUserId, + state: 'playing', + deviceId: 'device-2', + geoLat: TEST_LOCATIONS.tokyo.lat, + geoLon: TEST_LOCATIONS.tokyo.lon, + }), + ]; + + const currentSession = createMockSession({ + id: 'session-ny', + serverUserId, + state: 'playing', + deviceId: 'device-3', + geoLat: TEST_LOCATIONS.newYork.lat, + geoLon: TEST_LOCATIONS.newYork.lon, + }); + + const rule = createMockRule('simultaneous_locations', { + params: { minDistanceKm: 100 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + expect(results).toHaveLength(1); + expect(results[0]!.data.relatedSessionIds).toContain('session-la'); + expect(results[0]!.data.relatedSessionIds).toContain('session-tokyo'); + }); + }); + + describe('device_velocity', () => { + const serverUserId = 'user-123'; + + it('should not violate when unique IPs are within limit', async () => { + const sessions = createSessionsWithDifferentIps(serverUserId, 3, 24); + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.200', // 4th unique IP + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 5, windowHours: 24 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessions + ); + + expect(results).toHaveLength(0); + }); + + it('should violate when unique IPs exceed limit', async () => { + const sessions = createSessionsWithDifferentIps(serverUserId, 5, 24); + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.200', // 6th unique IP + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 5, windowHours: 24 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessions + ); + + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + expect(results[0]!.severity).toBe('warning'); + expect(results[0]!.data.uniqueIpCount).toBe(6); + expect(results[0]!.data.maxAllowedIps).toBe(5); + expect(results[0]!.data.windowHours).toBe(24); + }); + + it('should include current session IP in count', async () => { + // 4 unique IPs from previous sessions + 1 from current = 5 (at limit) + const sessions = createSessionsWithDifferentIps(serverUserId, 4, 24); + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.200', // 5th unique IP + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 5, windowHours: 24 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessions + ); + + expect(results).toHaveLength(0); // Exactly at limit, not over + }); + + it('should respect time window', async () => { + // Sessions outside the window should be ignored + const oldSessions = [ + createMockSession({ + serverUserId, + ipAddress: '192.168.1.1', + startedAt: new Date(Date.now() - 48 * 60 * 60 * 1000), // 48 hours ago + }), + createMockSession({ + serverUserId, + ipAddress: '192.168.1.2', + startedAt: new Date(Date.now() - 36 * 60 * 60 * 1000), // 36 hours ago + }), + ]; + + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.3', + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 2, windowHours: 24 }, // 24-hour window + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + oldSessions + ); + + // Old sessions are outside window, only current session IP counts + expect(results).toHaveLength(0); + }); + + it('should count sessions within window correctly', async () => { + const windowHours = 24; + const sessionsInWindow = [ + createMockSession({ + serverUserId, + ipAddress: '192.168.1.1', + startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000), // 23 hours ago (in window) + }), + createMockSession({ + serverUserId, + ipAddress: '192.168.1.2', + startedAt: new Date(Date.now() - 12 * 60 * 60 * 1000), // 12 hours ago + }), + ]; + + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.3', // 3rd unique IP + startedAt: new Date(), + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 2, windowHours }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessionsInWindow + ); + + expect(results).toHaveLength(1); + expect(results[0]!.data.uniqueIpCount).toBe(3); + }); + + it('should not double-count duplicate IPs', async () => { + // Multiple sessions from same IP should count as 1 + const sessions = [ + createMockSession({ serverUserId, ipAddress: '192.168.1.1' }), + createMockSession({ serverUserId, ipAddress: '192.168.1.1' }), // Same IP + createMockSession({ serverUserId, ipAddress: '192.168.1.2' }), + ]; + + const currentSession = createMockSession({ + serverUserId, + ipAddress: '192.168.1.1', // Same IP again + }); + + const rule = createMockRule('device_velocity', { + params: { maxIps: 2, windowHours: 24 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessions + ); + + // Only 2 unique IPs: 192.168.1.1 and 192.168.1.2 + expect(results).toHaveLength(0); + }); + }); + + describe('concurrent_streams', () => { + const serverUserId = 'user-123'; + + it('should not violate when streams are within limit', async () => { + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing' }), + createMockSession({ serverUserId, state: 'playing' }), + ]; + + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 3 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + // 2 existing + 1 current = 3 (at limit) + expect(results).toHaveLength(0); + }); + + it('should violate when streams exceed limit', async () => { + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing' }), + createMockSession({ serverUserId, state: 'playing' }), + createMockSession({ serverUserId, state: 'playing' }), + ]; + + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 3 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + // 3 existing + 1 current = 4 (exceeds limit of 3) + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + expect(results[0]!.severity).toBe('low'); + expect(results[0]!.data).toMatchObject({ + activeStreamCount: 4, + maxAllowedStreams: 3, + }); + }); + + it('should only count playing sessions', async () => { + const sessions = [ + createMockSession({ serverUserId, state: 'playing' }), + createMockSession({ serverUserId, state: 'paused' }), // Should not count + createMockSession({ serverUserId, state: 'stopped' }), // Should not count + ]; + + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 1 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + sessions + ); + + // Only 1 playing + 1 current = 2, exceeds limit of 1 + expect(results).toHaveLength(1); + expect(results[0]!.data.activeStreamCount).toBe(2); + }); + + it('should include current session in count', async () => { + // No existing sessions, just current + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 0 }, // Zero tolerance + }); + + const results = await ruleEngine.evaluateSession(currentSession, [rule], []); + + // Current session counts as 1 + expect(results).toHaveLength(1); + expect(results[0]!.data.activeStreamCount).toBe(1); + }); + + it('should ignore sessions from different users', async () => { + const otherUserSessions = [ + createMockSession({ serverUserId: 'other-user-1', state: 'playing' }), + createMockSession({ serverUserId: 'other-user-2', state: 'playing' }), + ]; + + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 1 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + otherUserSessions + ); + + // Only current session counts for this user + expect(results).toHaveLength(0); + }); + + it('should exclude sessions from the same device (reconnects/stale sessions)', async () => { + const sharedDeviceId = 'device-shared-123'; + + // Two sessions from same device - should be treated as one stream + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing', deviceId: sharedDeviceId }), + createMockSession({ serverUserId, state: 'playing', deviceId: 'device-other-456' }), + ]; + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: sharedDeviceId, // Same device as first active session + }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 2 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + // Should only count 2 streams: current session + device-other-456 + // The device-shared-123 session should be excluded (same device as current) + expect(results).toHaveLength(0); + }); + + it('should not exclude sessions when deviceId is null on current session', async () => { + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing', deviceId: 'device-123' }), + createMockSession({ serverUserId, state: 'playing', deviceId: 'device-456' }), + ]; + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: null, // No device ID + }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 2 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + // Should count all 3 streams since we can't determine device identity + expect(results).toHaveLength(1); + expect(results[0]!.data.activeStreamCount).toBe(3); + }); + + it('should not exclude sessions when deviceId is null on active session', async () => { + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing', deviceId: null }), // No device ID + createMockSession({ serverUserId, state: 'playing', deviceId: 'device-456' }), + ]; + + const currentSession = createMockSession({ + serverUserId, + state: 'playing', + deviceId: 'device-123', + }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 2 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + // Should count all 3 streams since null deviceId can't be matched + expect(results).toHaveLength(1); + expect(results[0]!.data.activeStreamCount).toBe(3); + }); + + it('should include relatedSessionIds in violation data', async () => { + const activeSessions = [ + createMockSession({ id: 'session-1', serverUserId, state: 'playing', deviceId: 'device-1' }), + createMockSession({ id: 'session-2', serverUserId, state: 'playing', deviceId: 'device-2' }), + ]; + + const currentSession = createMockSession({ + id: 'session-current', + serverUserId, + state: 'playing', + deviceId: 'device-current', + }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 2 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + expect(results).toHaveLength(1); + expect(results[0]!.data.relatedSessionIds).toEqual(['session-1', 'session-2']); + }); + }); + + describe('geo_restriction', () => { + it('should not violate when country is not blocked', async () => { + const session = createMockSession({ + geoCountry: 'US', + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: ['CN', 'RU', 'KP'] }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(0); + }); + + it('should violate when country is blocked', async () => { + const session = createMockSession({ + geoCountry: 'CN', + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: ['CN', 'RU', 'KP'] }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + + expect(results).toHaveLength(1); + expect(results[0]!.violated).toBe(true); + expect(results[0]!.severity).toBe('high'); + expect(results[0]!.data).toMatchObject({ + country: 'CN', + blockedCountries: ['CN', 'RU', 'KP'], + }); + }); + + it('should not violate when geoCountry is null', async () => { + const session = createMockSession({ + geoCountry: null, + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: ['CN', 'RU'] }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(0); + }); + + it('should not violate when blockedCountries is empty', async () => { + const session = createMockSession({ + geoCountry: 'CN', + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: [] }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(0); + }); + + it('should be case-sensitive for country codes', async () => { + // Country codes should be uppercase + const session = createMockSession({ + geoCountry: 'cn', // lowercase + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: ['CN'] }, // uppercase + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + + // Case-sensitive, so 'cn' !== 'CN' + expect(results).toHaveLength(0); + }); + + it('should violate for any blocked country in list', async () => { + const session = createMockSession({ + geoCountry: 'KP', // Last in the list + }); + + const rule = createMockRule('geo_restriction', { + params: { blockedCountries: ['CN', 'RU', 'KP'] }, + }); + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(1); + expect(results[0]!.data.country).toBe('KP'); + }); + }); + + describe('unknown rule type', () => { + it('should return no violation for unknown rule type', async () => { + const session = createMockSession(); + const rule = { + ...createMockRule('concurrent_streams'), + type: 'unknown_type' as any, + }; + + const results = await ruleEngine.evaluateSession(session, [rule], []); + expect(results).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should handle empty recent sessions array', async () => { + const session = createMockSession(); + const rules = [ + createMockRule('impossible_travel'), + createMockRule('simultaneous_locations'), + createMockRule('device_velocity'), + createMockRule('concurrent_streams'), + createMockRule('geo_restriction', { + params: { blockedCountries: ['CN'] }, + }), + ]; + + const results = await ruleEngine.evaluateSession(session, rules, []); + + // Only concurrent_streams should trigger (1 stream when maxStreams defaults to 3) + // geo_restriction shouldn't trigger because session is US + expect(results.length).toBeLessThanOrEqual(1); + }); + + it('should handle session at exactly the boundary', async () => { + const serverUserId = 'user-123'; + + // Exactly at the maxStreams limit + const activeSessions = [ + createMockSession({ serverUserId, state: 'playing' }), + ]; + + const currentSession = createMockSession({ serverUserId, state: 'playing' }); + + const rule = createMockRule('concurrent_streams', { + params: { maxStreams: 2 }, // 1 + 1 = 2, exactly at limit + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + activeSessions + ); + + expect(results).toHaveLength(0); // At limit, not over + }); + + it('should handle very large distances correctly', async () => { + const serverUserId = 'user-123'; + // Antipodal points (opposite sides of Earth) + const previousSession = createMockSession({ + serverUserId, + geoLat: 0, + geoLon: 0, + startedAt: new Date(Date.now() - 1 * 60 * 60 * 1000), // 1 hour ago + }); + + const currentSession = createMockSession({ + serverUserId, + geoLat: 0, + geoLon: 180, // Opposite side of Earth + startedAt: new Date(), + }); + + const rule = createMockRule('impossible_travel', { + params: { maxSpeedKmh: 500 }, + }); + + const results = await ruleEngine.evaluateSession( + currentSession, + [rule], + [previousSession] + ); + + expect(results).toHaveLength(1); + // Earth's circumference at equator is ~40,075 km, half is ~20,000 km + // Speed would be ~20,000 km/h + expect(results[0]!.data.calculatedSpeed).toBeGreaterThan(15000); + }); + }); +}); diff --git a/apps/server/src/services/__tests__/tautulli.test.ts b/apps/server/src/services/__tests__/tautulli.test.ts new file mode 100644 index 0000000..ade3842 --- /dev/null +++ b/apps/server/src/services/__tests__/tautulli.test.ts @@ -0,0 +1,1580 @@ +/** + * Tautulli Import Service Tests + * + * Comprehensive tests covering: + * - Zod schema validation against real API data structures + * - User matching and skip behavior + * - Session deduplication logic + * - GeoIP integration + * - Field mapping (movies, episodes, tracks) + * - Retry/timeout behavior + * - Progress tracking + */ + +import { describe, it, expect } from 'vitest'; + +// Import ACTUAL production schemas - not local duplicates +// This ensures tests validate the same schemas used in production +import { + TautulliHistoryRecordSchema, + TautulliHistoryResponseSchema, + TautulliUserRecordSchema, + TautulliUsersResponseSchema, + type TautulliHistoryRecord, + type TautulliUserRecord, +} from '../tautulli.js'; + +// ============================================================================ +// REAL API TEST DATA (captured from actual Tautulli instance) +// ============================================================================ + +const REAL_MOVIE_RECORD: TautulliHistoryRecord = { + reference_id: 11650, + row_id: 11650, + id: 11650, + date: 1764488126, + started: 1764488126, + stopped: 1764494418, + duration: 6292, + play_duration: 6292, + paused_counter: 0, + user_id: 374704766, + user: 'lukelino', + friendly_name: 'Luke Lino', + user_thumb: 'https://plex.tv/users/abc123/avatar', + platform: 'Android', + product: 'Plex for Android (TV)', + player: 'AFTMM', + ip_address: '73.160.197.140', + live: 0, + machine_id: 'f7df5a3c0a1f6134-com-plexapp-android', + location: 'wan', + secure: 1, + relayed: 0, + media_type: 'movie', + rating_key: 25314, + parent_rating_key: '', + grandparent_rating_key: '', + full_title: 'How the Grinch Stole Christmas', + title: 'How the Grinch Stole Christmas', + parent_title: '', + grandparent_title: '', + original_title: '', + year: 2000, + media_index: '', + parent_media_index: '', + thumb: '/library/metadata/25314/thumb/1762510145', + originally_available_at: '2000-11-17', + guid: 'plex://movie/5d7768324de0ee001fccac77', + transcode_decision: 'transcode', + percent_complete: 100, + watched_status: 1, + group_count: 1, + group_ids: '11650', + state: null, + session_key: null, +}; + +// Real episode record from actual API - note year IS a number for properly matched media +const REAL_EPISODE_RECORD: TautulliHistoryRecord = { + reference_id: 11627, + row_id: 11651, + id: 11651, + date: 1764524187, + started: 1764296397, + stopped: 1764527203, + duration: 3638, + play_duration: 3638, + paused_counter: 688, + user_id: 302613764, + user: 'ethanbyrum', + friendly_name: 'Ethan Byrum', + user_thumb: 'https://plex.tv/users/4345d655aa21f449/avatar?c=1764494415', + platform: 'tvOS', + product: 'Plex for Apple TV', + player: 'Apple TV', + ip_address: '104.128.161.124', + live: 0, + machine_id: '7C3AFE13-4063-46E6-94AD-92BC94337DDB', + location: 'wan', + secure: 1, + relayed: 0, + media_type: 'episode', + rating_key: 128459, + parent_rating_key: 128458, + grandparent_rating_key: 110397, + full_title: 'Ozark - Wartime', + title: 'Wartime', + parent_title: 'Season 3', + grandparent_title: 'Ozark', + original_title: '', + year: 2020, // Episodes with proper metadata have numeric year + media_index: 1, + parent_media_index: 3, + thumb: '/library/metadata/128458/thumb/1764384847', + originally_available_at: '2020-03-27', + guid: 'plex://episode/5e8338265161b50041c98783', + transcode_decision: 'direct play', + percent_complete: 95, + watched_status: 1, + group_count: 2, + group_ids: '11627,11651', + state: null, + session_key: null, +}; + +const REAL_TRACK_RECORD: TautulliHistoryRecord = { + reference_id: 10132, + row_id: 10132, + id: 10132, + date: 1759030514, + started: 1759030514, + stopped: 1759030705, + duration: 191, + play_duration: 191, + paused_counter: 0, + user_id: 3453396, + user: 'rdweaver79', + friendly_name: 'Ryan Weaver', + user_thumb: 'https://plex.tv/users/def456/avatar', + platform: 'Roku', + product: 'Plex for Roku', + player: 'Basement Roku', + ip_address: '73.130.92.216', + live: 0, + machine_id: '1e9d2035fd07d7f7f71289521b1642db', + location: 'wan', + secure: 1, + relayed: 0, + media_type: 'track', + rating_key: 37963, + parent_rating_key: 37959, + grandparent_rating_key: 29070, + full_title: 'Watch Out for This (Bumaye) - Major Lazer', + title: 'Watch Out for This (Bumaye)', + parent_title: 'Major Lazer Sped Up', + grandparent_title: 'Major Lazer', + original_title: '', + year: 2022, + media_index: 4, + parent_media_index: 1, + thumb: '/library/metadata/37959/thumb/1732335189', + originally_available_at: '', + guid: 'plex://track/63332319a5c478aa2b0be398', + transcode_decision: 'direct play', + percent_complete: 0, + watched_status: 0, + group_count: 1, + group_ids: '10132', + state: null, + session_key: null, +}; + +const PARTIAL_WATCH_RECORD: TautulliHistoryRecord = { + ...REAL_MOVIE_RECORD, + reference_id: 11302, + row_id: 11647, + id: 11647, + percent_complete: 77, + watched_status: 0.75, // Decimal for partial watch + paused_counter: 4205, +}; + +// Unmatched/local media has empty string for year (guid starts with local://) +const UNMATCHED_EPISODE_RECORD: TautulliHistoryRecord = { + reference_id: 11595, + row_id: 11595, + id: 11595, + date: 1764140689, + started: 1764140689, + stopped: 1764141093, + duration: 404, + play_duration: 404, + paused_counter: 0, + user_id: 330457530, + user: 'gwh.h', + friendly_name: 'Greg Howard Jr.', + user_thumb: 'https://plex.tv/users/6fac0fa09f00d318/avatar?c=1764496979', + platform: 'Android', + product: 'Plex for Android (Mobile)', + player: 'Pixel 7 Pro', + ip_address: '136.23.58.217', + live: 0, + machine_id: '7fb4d017bd220361-com-plexapp-android', + location: 'wan', + secure: 1, + relayed: 0, + media_type: 'episode', + rating_key: 127775, + parent_rating_key: 127774, + grandparent_rating_key: 127773, + full_title: '1923 - Episode 1', + title: 'Episode 1', + parent_title: 'Season 1', + grandparent_title: '1923', + original_title: '', + year: '', // Unmatched/local media has empty string for year + media_index: 1, + parent_media_index: 1, + thumb: '/library/metadata/127773/thumb/1764139222', + originally_available_at: '', + guid: 'local://127775', // Note: local:// guid indicates unmatched media + transcode_decision: 'direct play', + percent_complete: 3, + watched_status: 0, + group_count: 1, + group_ids: '11595', + state: null, + session_key: null, +}; + +const LOCAL_USER: TautulliUserRecord = { + user_id: 0, + username: 'Local', + friendly_name: 'Local', + thumb: null, + email: null, + is_home_user: null, + is_admin: 0, + is_active: 1, + do_notify: 1, +}; + +const NORMAL_USER: TautulliUserRecord = { + user_id: 150112024, + username: 'Gallapagos', + friendly_name: 'Gallapagos', + thumb: 'https://plex.tv/users/ceac7bee6aac8175/avatar?c=1764478418', + email: 'connor.gallopo@gmail.com', + is_home_user: 1, + is_admin: 1, + is_active: 1, + do_notify: 1, +}; + +// ============================================================================ +// SCHEMA VALIDATION TESTS +// ============================================================================ + +describe('TautulliHistoryRecordSchema', () => { + describe('movie records', () => { + it('should validate a real movie record', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + }); + + it('should handle empty strings for parent/grandparent rating_key in movies', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.parent_rating_key).toBe(''); + expect(result.data.grandparent_rating_key).toBe(''); + } + }); + + it('should handle empty strings for media_index in movies', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.media_index).toBe(''); + expect(result.data.parent_media_index).toBe(''); + } + }); + + it('should handle numeric rating_key for movies', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rating_key).toBe(25314); + } + }); + }); + + describe('episode records', () => { + it('should validate a real episode record', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_EPISODE_RECORD); + expect(result.success).toBe(true); + }); + + it('should handle numeric parent/grandparent rating_key in episodes', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.parent_rating_key).toBe(128458); + expect(result.data.grandparent_rating_key).toBe(110397); + } + }); + + it('should handle numeric media_index in episodes', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.media_index).toBe(1); + expect(result.data.parent_media_index).toBe(3); + } + }); + + it('should preserve parent_title and grandparent_title for episodes', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.parent_title).toBe('Season 3'); + expect(result.data.grandparent_title).toBe('Ozark'); + } + }); + + it('should handle numeric year for properly matched episodes', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.year).toBe(2020); + } + }); + }); + + describe('unmatched/local media records', () => { + it('should validate unmatched media with empty string year', () => { + const result = TautulliHistoryRecordSchema.safeParse(UNMATCHED_EPISODE_RECORD); + expect(result.success).toBe(true); + }); + + it('should handle empty string year for unmatched media', () => { + const result = TautulliHistoryRecordSchema.safeParse(UNMATCHED_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.year).toBe(''); + } + }); + + it('should handle empty originally_available_at for unmatched media', () => { + const result = TautulliHistoryRecordSchema.safeParse(UNMATCHED_EPISODE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.originally_available_at).toBe(''); + } + }); + + it('should identify local guid pattern', () => { + expect(UNMATCHED_EPISODE_RECORD.guid).toMatch(/^local:\/\//); + }); + }); + + describe('track records', () => { + it('should validate a real track/music record', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_TRACK_RECORD); + expect(result.success).toBe(true); + }); + + it('should handle empty originally_available_at for tracks', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_TRACK_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.originally_available_at).toBe(''); + } + }); + }); + + describe('partial watch records', () => { + it('should handle decimal watched_status (0.75)', () => { + const result = TautulliHistoryRecordSchema.safeParse(PARTIAL_WATCH_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.watched_status).toBe(0.75); + } + }); + + it('should handle non-zero paused_counter', () => { + const result = TautulliHistoryRecordSchema.safeParse(PARTIAL_WATCH_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.paused_counter).toBe(4205); + } + }); + }); + + describe('null fields', () => { + it('should handle null state field', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.state).toBeNull(); + } + }); + + it('should handle null session_key field', () => { + const result = TautulliHistoryRecordSchema.safeParse(REAL_MOVIE_RECORD); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.session_key).toBeNull(); + } + }); + }); + + describe('edge cases', () => { + it('should reject records with missing required fields', () => { + const invalidRecord = { ...REAL_MOVIE_RECORD }; + delete (invalidRecord as any).reference_id; + const result = TautulliHistoryRecordSchema.safeParse(invalidRecord); + expect(result.success).toBe(false); + }); + + it('should reject records with wrong types', () => { + const invalidRecord = { ...REAL_MOVIE_RECORD, reference_id: 'not-a-number' }; + const result = TautulliHistoryRecordSchema.safeParse(invalidRecord); + expect(result.success).toBe(false); + }); + + it('should strip extra fields and still validate', () => { + const recordWithExtras = { + ...REAL_MOVIE_RECORD, + id: 11650, // Extra field from API + play_duration: 6292, // Extra field from API + user_thumb: 'https://example.com/thumb', // Extra field from API + }; + const result = TautulliHistoryRecordSchema.safeParse(recordWithExtras); + expect(result.success).toBe(true); + }); + }); +}); + +describe('TautulliUserRecordSchema', () => { + describe('local user (null fields)', () => { + it('should validate a local user with null thumb/email/is_home_user', () => { + const result = TautulliUserRecordSchema.safeParse(LOCAL_USER); + expect(result.success).toBe(true); + }); + + it('should handle null thumb', () => { + const result = TautulliUserRecordSchema.safeParse(LOCAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.thumb).toBeNull(); + } + }); + + it('should handle null email', () => { + const result = TautulliUserRecordSchema.safeParse(LOCAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.email).toBeNull(); + } + }); + + it('should handle null is_home_user', () => { + const result = TautulliUserRecordSchema.safeParse(LOCAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.is_home_user).toBeNull(); + } + }); + + it('should handle user_id of 0 for local user', () => { + const result = TautulliUserRecordSchema.safeParse(LOCAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user_id).toBe(0); + } + }); + }); + + describe('normal user (populated fields)', () => { + it('should validate a normal user with all fields', () => { + const result = TautulliUserRecordSchema.safeParse(NORMAL_USER); + expect(result.success).toBe(true); + }); + + it('should preserve thumb URL', () => { + const result = TautulliUserRecordSchema.safeParse(NORMAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.thumb).toContain('plex.tv'); + } + }); + + it('should preserve email', () => { + const result = TautulliUserRecordSchema.safeParse(NORMAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.email).toContain('@'); + } + }); + + it('should handle is_home_user as number', () => { + const result = TautulliUserRecordSchema.safeParse(NORMAL_USER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.is_home_user).toBe(1); + } + }); + }); + + describe('edge cases', () => { + it('should strip extra fields from user records', () => { + const userWithExtras = { + ...NORMAL_USER, + row_id: 2, + is_allow_sync: 1, + is_restricted: 0, + keep_history: 1, + allow_guest: 0, + shared_libraries: ['3', '2'], + }; + const result = TautulliUserRecordSchema.safeParse(userWithExtras); + expect(result.success).toBe(true); + }); + + it('should reject user with missing required fields', () => { + const invalidUser = { ...NORMAL_USER }; + delete (invalidUser as any).user_id; + const result = TautulliUserRecordSchema.safeParse(invalidUser); + expect(result.success).toBe(false); + }); + }); +}); + +describe('TautulliHistoryResponseSchema', () => { + it('should validate a complete history response', () => { + const response = { + response: { + result: 'success', + message: null, + data: { + recordsFiltered: 7993, + recordsTotal: 11650, + data: [REAL_MOVIE_RECORD, REAL_EPISODE_RECORD], + draw: 1, + filter_duration: '4 hrs 31 mins 55 secs', + total_duration: '469 days 2 hrs 26 mins', + }, + }, + }; + const result = TautulliHistoryResponseSchema.safeParse(response); + expect(result.success).toBe(true); + }); + + it('should handle empty data array', () => { + const response = { + response: { + result: 'success', + message: null, + data: { + recordsFiltered: 0, + recordsTotal: 0, + data: [], + draw: 1, + filter_duration: '0 secs', + total_duration: '0 secs', + }, + }, + }; + const result = TautulliHistoryResponseSchema.safeParse(response); + expect(result.success).toBe(true); + }); + + it('should reject response with invalid result', () => { + const response = { + response: { + result: 123, // Should be string + message: null, + data: { + recordsFiltered: 0, + recordsTotal: 0, + data: [], + draw: 1, + filter_duration: '0 secs', + total_duration: '0 secs', + }, + }, + }; + const result = TautulliHistoryResponseSchema.safeParse(response); + expect(result.success).toBe(false); + }); +}); + +describe('TautulliUsersResponseSchema', () => { + it('should validate a complete users response', () => { + const response = { + response: { + result: 'success', + message: null, + data: [LOCAL_USER, NORMAL_USER], + }, + }; + const result = TautulliUsersResponseSchema.safeParse(response); + expect(result.success).toBe(true); + }); + + it('should handle empty users array', () => { + const response = { + response: { + result: 'success', + message: null, + data: [], + }, + }; + const result = TautulliUsersResponseSchema.safeParse(response); + expect(result.success).toBe(true); + }); +}); + +// ============================================================================ +// FIELD MAPPING TESTS +// ============================================================================ + +describe('Field Mapping', () => { + describe('media type detection', () => { + it('should correctly identify movie media type', () => { + expect(REAL_MOVIE_RECORD.media_type).toBe('movie'); + }); + + it('should correctly identify episode media type', () => { + expect(REAL_EPISODE_RECORD.media_type).toBe('episode'); + }); + + it('should correctly identify track media type', () => { + expect(REAL_TRACK_RECORD.media_type).toBe('track'); + }); + }); + + describe('rating_key type coercion', () => { + function convertRatingKey(ratingKey: number | ''): string | null { + return typeof ratingKey === 'number' ? String(ratingKey) : null; + } + + it('should convert numeric rating_key to string', () => { + const result = convertRatingKey(REAL_MOVIE_RECORD.rating_key); + expect(result).toBe('25314'); + }); + + it('should convert empty string rating_key to null', () => { + const result = convertRatingKey('' as const); + expect(result).toBeNull(); + }); + }); + + describe('media_index type coercion', () => { + function convertMediaIndex(mediaIndex: number | ''): number | null { + return typeof mediaIndex === 'number' ? mediaIndex : null; + } + + it('should preserve numeric media_index', () => { + const result = convertMediaIndex(REAL_EPISODE_RECORD.media_index); + expect(result).toBe(1); + }); + + it('should convert empty string media_index to null', () => { + const result = convertMediaIndex('' as const); + expect(result).toBeNull(); + }); + }); + + describe('reference_id to externalSessionId', () => { + it('should convert numeric reference_id to string', () => { + const externalSessionId = String(REAL_MOVIE_RECORD.reference_id); + expect(externalSessionId).toBe('11650'); + expect(typeof externalSessionId).toBe('string'); + }); + }); + + describe('timestamp conversions', () => { + it('should convert started timestamp to Date', () => { + const startedAt = new Date(REAL_MOVIE_RECORD.started * 1000); + expect(startedAt).toBeInstanceOf(Date); + expect(startedAt.getTime()).toBeGreaterThan(0); + }); + + it('should convert stopped timestamp to Date', () => { + const stoppedAt = new Date(REAL_MOVIE_RECORD.stopped * 1000); + expect(stoppedAt).toBeInstanceOf(Date); + expect(stoppedAt.getTime()).toBeGreaterThan(0); + }); + + it('should convert duration to milliseconds', () => { + const durationMs = REAL_MOVIE_RECORD.duration * 1000; + expect(durationMs).toBe(6292000); + }); + + it('should convert paused_counter to milliseconds', () => { + const pausedDurationMs = PARTIAL_WATCH_RECORD.paused_counter * 1000; + expect(pausedDurationMs).toBe(4205000); + }); + }); + + describe('watched status conversion', () => { + it('should mark watched_status === 1 as watched', () => { + const watched = REAL_MOVIE_RECORD.watched_status === 1; + expect(watched).toBe(true); + }); + + it('should mark watched_status < 1 as not watched', () => { + const watched = PARTIAL_WATCH_RECORD.watched_status === 1; + expect(watched).toBe(false); + }); + + it('should handle decimal watched_status', () => { + expect(PARTIAL_WATCH_RECORD.watched_status).toBe(0.75); + expect(PARTIAL_WATCH_RECORD.watched_status < 1).toBe(true); + }); + }); + + describe('transcode detection', () => { + it('should detect transcode decision', () => { + const isTranscode = REAL_MOVIE_RECORD.transcode_decision === 'transcode'; + expect(isTranscode).toBe(true); + }); + + it('should detect direct play', () => { + const isTranscode = REAL_EPISODE_RECORD.transcode_decision === 'transcode'; + expect(isTranscode).toBe(false); + }); + }); + + describe('quality mapping', () => { + it('should map transcode to quality string', () => { + const quality = REAL_MOVIE_RECORD.transcode_decision === 'transcode' ? 'Transcode' : 'Direct'; + expect(quality).toBe('Transcode'); + }); + + it('should map direct play to quality string', () => { + const quality = REAL_EPISODE_RECORD.transcode_decision === 'transcode' ? 'Transcode' : 'Direct'; + expect(quality).toBe('Direct'); + }); + }); +}); + +// ============================================================================ +// USER MATCHING TESTS +// ============================================================================ + +describe('User Matching Logic', () => { + // Simulated user lookup function + function findUserByExternalId( + users: Array<{ externalId: string; id: string }>, + tautulliUserId: number + ): string | null { + const user = users.find((u) => u.externalId === String(tautulliUserId)); + return user?.id ?? null; + } + + it('should match user by externalId (Tautulli user_id)', () => { + const tracearrUsers = [ + { externalId: '374704766', id: 'uuid-1' }, + { externalId: '150112024', id: 'uuid-2' }, + ]; + const result = findUserByExternalId(tracearrUsers, REAL_MOVIE_RECORD.user_id); + expect(result).toBe('uuid-1'); + }); + + it('should return null for unmatched user', () => { + const tracearrUsers = [{ externalId: '999999', id: 'uuid-1' }]; + const result = findUserByExternalId(tracearrUsers, REAL_MOVIE_RECORD.user_id); + expect(result).toBeNull(); + }); + + it('should handle local user (user_id: 0)', () => { + const tracearrUsers = [{ externalId: '0', id: 'uuid-local' }]; + const result = findUserByExternalId(tracearrUsers, LOCAL_USER.user_id); + expect(result).toBe('uuid-local'); + }); + + describe('skip user tracking', () => { + interface SkippedUser { + tautulliUserId: number; + username: string; + count: number; + } + + function trackSkippedUser( + skippedUsers: Map, + record: TautulliHistoryRecord + ): void { + const existing = skippedUsers.get(record.user_id); + if (existing) { + existing.count++; + } else { + skippedUsers.set(record.user_id, { + tautulliUserId: record.user_id, + username: record.friendly_name || record.user, + count: 1, + }); + } + } + + it('should track first occurrence of skipped user', () => { + const skippedUsers = new Map(); + trackSkippedUser(skippedUsers, REAL_MOVIE_RECORD); + + expect(skippedUsers.size).toBe(1); + expect(skippedUsers.get(374704766)).toEqual({ + tautulliUserId: 374704766, + username: 'Luke Lino', + count: 1, + }); + }); + + it('should increment count for repeated skipped user', () => { + const skippedUsers = new Map(); + // Use same user_id record twice to test increment + trackSkippedUser(skippedUsers, REAL_MOVIE_RECORD); + trackSkippedUser(skippedUsers, { ...REAL_MOVIE_RECORD, reference_id: 99999 }); // Same user_id + + expect(skippedUsers.size).toBe(1); + expect(skippedUsers.get(374704766)?.count).toBe(2); + }); + + it('should track multiple different skipped users', () => { + const skippedUsers = new Map(); + trackSkippedUser(skippedUsers, REAL_MOVIE_RECORD); + trackSkippedUser(skippedUsers, REAL_TRACK_RECORD); // Different user_id + + expect(skippedUsers.size).toBe(2); + expect(skippedUsers.has(374704766)).toBe(true); + expect(skippedUsers.has(3453396)).toBe(true); + }); + }); +}); + +// ============================================================================ +// DEDUPLICATION TESTS +// ============================================================================ + +describe('Deduplication Logic', () => { + interface SessionRecord { + id: string; + serverId: string; + externalSessionId: string | null; + ratingKey: string | null; + startedAt: Date; + userId: string; + } + + // Simulated dedup check functions + function findByExternalSessionId( + sessions: SessionRecord[], + serverId: string, + externalSessionId: string + ): SessionRecord | undefined { + return sessions.find( + (s) => s.serverId === serverId && s.externalSessionId === externalSessionId + ); + } + + function findByRatingKeyAndTime( + sessions: SessionRecord[], + serverId: string, + userId: string, + ratingKey: string, + startedAt: Date + ): SessionRecord | undefined { + return sessions.find( + (s) => + s.serverId === serverId && + s.userId === userId && + s.ratingKey === ratingKey && + s.startedAt.getTime() === startedAt.getTime() + ); + } + + describe('externalSessionId deduplication', () => { + it('should find existing session by externalSessionId', () => { + const existingSessions: SessionRecord[] = [ + { + id: 'uuid-1', + serverId: 'server-1', + externalSessionId: '11650', + ratingKey: '25314', + startedAt: new Date(1764488126 * 1000), + userId: 'user-1', + }, + ]; + + const result = findByExternalSessionId(existingSessions, 'server-1', '11650'); + expect(result).toBeDefined(); + expect(result?.id).toBe('uuid-1'); + }); + + it('should not match different server', () => { + const existingSessions: SessionRecord[] = [ + { + id: 'uuid-1', + serverId: 'server-1', + externalSessionId: '11650', + ratingKey: '25314', + startedAt: new Date(1764488126 * 1000), + userId: 'user-1', + }, + ]; + + const result = findByExternalSessionId(existingSessions, 'server-2', '11650'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-existent externalSessionId', () => { + const existingSessions: SessionRecord[] = []; + const result = findByExternalSessionId(existingSessions, 'server-1', '99999'); + expect(result).toBeUndefined(); + }); + }); + + describe('ratingKey + startedAt fallback deduplication', () => { + it('should find existing session by ratingKey and startedAt', () => { + const startedAt = new Date(1764488126 * 1000); + const existingSessions: SessionRecord[] = [ + { + id: 'uuid-1', + serverId: 'server-1', + externalSessionId: null, // No external ID yet + ratingKey: '25314', + startedAt, + userId: 'user-1', + }, + ]; + + const result = findByRatingKeyAndTime( + existingSessions, + 'server-1', + 'user-1', + '25314', + startedAt + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('uuid-1'); + }); + + it('should not match different startedAt time', () => { + const existingSessions: SessionRecord[] = [ + { + id: 'uuid-1', + serverId: 'server-1', + externalSessionId: null, + ratingKey: '25314', + startedAt: new Date(1764488126 * 1000), + userId: 'user-1', + }, + ]; + + const result = findByRatingKeyAndTime( + existingSessions, + 'server-1', + 'user-1', + '25314', + new Date(1764488127 * 1000) // 1 second later + ); + expect(result).toBeUndefined(); + }); + + it('should not match different user', () => { + const startedAt = new Date(1764488126 * 1000); + const existingSessions: SessionRecord[] = [ + { + id: 'uuid-1', + serverId: 'server-1', + externalSessionId: null, + ratingKey: '25314', + startedAt, + userId: 'user-1', + }, + ]; + + const result = findByRatingKeyAndTime( + existingSessions, + 'server-1', + 'user-2', // Different user + '25314', + startedAt + ); + expect(result).toBeUndefined(); + }); + + it('should skip fallback dedup when ratingKey is null', () => { + const ratingKeyStr = typeof REAL_MOVIE_RECORD.rating_key === 'number' + ? String(REAL_MOVIE_RECORD.rating_key) + : null; + + const emptyRatingKeyStr = typeof ('' as number | '') === 'number' + ? String('' as number | '') + : null; + + expect(ratingKeyStr).toBe('25314'); + expect(emptyRatingKeyStr).toBeNull(); + }); + }); +}); + +// ============================================================================ +// GEOIP INTEGRATION TESTS +// ============================================================================ + +describe('GeoIP Integration', () => { + // Simulated GeoIP lookup result + interface GeoResult { + city: string | null; + region: string | null; + country: string | null; + lat: number | null; + lon: number | null; + } + + // Mock GeoIP service + function mockGeoipLookup(ipAddress: string): GeoResult { + // Simulate real lookups + const ipDatabase: Record = { + '73.160.197.140': { + city: 'Jersey City', + region: 'New Jersey', + country: 'US', + lat: 40.7282, + lon: -74.0776, + }, + '192.168.1.126': { + // Private IP - no geo data + city: null, + region: null, + country: null, + lat: null, + lon: null, + }, + '73.130.92.216': { + city: 'Columbus', + region: 'Ohio', + country: 'US', + lat: 39.9612, + lon: -82.9988, + }, + }; + + return ( + ipDatabase[ipAddress] ?? { + city: null, + region: null, + country: null, + lat: null, + lon: null, + } + ); + } + + it('should resolve public IP to geo data', () => { + const geo = mockGeoipLookup(REAL_MOVIE_RECORD.ip_address); + expect(geo.city).toBe('Jersey City'); + expect(geo.region).toBe('New Jersey'); + expect(geo.country).toBe('US'); + expect(geo.lat).toBeCloseTo(40.7282, 2); + expect(geo.lon).toBeCloseTo(-74.0776, 2); + }); + + it('should return nulls for private IP', () => { + const privateIp = '192.168.1.126'; + const geo = mockGeoipLookup(privateIp); + expect(geo.city).toBeNull(); + expect(geo.region).toBeNull(); + expect(geo.country).toBeNull(); + expect(geo.lat).toBeNull(); + expect(geo.lon).toBeNull(); + }); + + it('should gracefully handle unknown IP', () => { + const geo = mockGeoipLookup('1.2.3.4'); + expect(geo.city).toBeNull(); + expect(geo.lat).toBeNull(); + }); + + it('should use IP from record for lookup', () => { + // Movie record has public IP + const movieGeo = mockGeoipLookup(REAL_MOVIE_RECORD.ip_address); + expect(movieGeo.country).toBe('US'); + + // Track record has different public IP + const trackGeo = mockGeoipLookup(REAL_TRACK_RECORD.ip_address); + expect(trackGeo.city).toBe('Columbus'); + }); +}); + +// ============================================================================ +// PROGRESS TRACKING TESTS +// ============================================================================ + +describe('Progress Tracking', () => { + interface ImportProgress { + phase: 'users' | 'history'; + currentPage: number; + totalRecords: number; + importedRecords: number; + skippedRecords: number; + errorRecords: number; + startedAt: Date; + } + + function createProgress(): ImportProgress { + return { + phase: 'users', + currentPage: 0, + totalRecords: 0, + importedRecords: 0, + skippedRecords: 0, + errorRecords: 0, + startedAt: new Date(), + }; + } + + function updateProgress( + progress: ImportProgress, + updates: Partial + ): ImportProgress { + return { ...progress, ...updates }; + } + + it('should initialize progress with correct defaults', () => { + const progress = createProgress(); + expect(progress.phase).toBe('users'); + expect(progress.currentPage).toBe(0); + expect(progress.totalRecords).toBe(0); + expect(progress.importedRecords).toBe(0); + expect(progress.skippedRecords).toBe(0); + expect(progress.errorRecords).toBe(0); + expect(progress.startedAt).toBeInstanceOf(Date); + }); + + it('should update phase from users to history', () => { + let progress = createProgress(); + progress = updateProgress(progress, { phase: 'history' }); + expect(progress.phase).toBe('history'); + }); + + it('should track total records from API response', () => { + let progress = createProgress(); + const apiResponse = { recordsTotal: 11650 }; + progress = updateProgress(progress, { totalRecords: apiResponse.recordsTotal }); + expect(progress.totalRecords).toBe(11650); + }); + + it('should increment imported records', () => { + let progress = createProgress(); + progress = updateProgress(progress, { importedRecords: progress.importedRecords + 1 }); + progress = updateProgress(progress, { importedRecords: progress.importedRecords + 1 }); + progress = updateProgress(progress, { importedRecords: progress.importedRecords + 1 }); + expect(progress.importedRecords).toBe(3); + }); + + it('should increment skipped records for duplicates', () => { + let progress = createProgress(); + progress = updateProgress(progress, { skippedRecords: progress.skippedRecords + 1 }); + expect(progress.skippedRecords).toBe(1); + }); + + it('should increment error records on failure', () => { + let progress = createProgress(); + progress = updateProgress(progress, { errorRecords: progress.errorRecords + 1 }); + expect(progress.errorRecords).toBe(1); + }); + + it('should track page progression', () => { + let progress = createProgress(); + // Simulating pagination through pages (page size would be 100 in real import) + + for (let page = 1; page <= 3; page++) { + progress = updateProgress(progress, { currentPage: page }); + } + + expect(progress.currentPage).toBe(3); + }); + + it('should calculate completion percentage', () => { + const progress = { + ...createProgress(), + totalRecords: 1000, + importedRecords: 250, + skippedRecords: 50, + errorRecords: 0, + }; + + const processed = progress.importedRecords + progress.skippedRecords + progress.errorRecords; + const percentage = Math.round((processed / progress.totalRecords) * 100); + expect(percentage).toBe(30); + }); +}); + +// ============================================================================ +// ERROR HANDLING AND RETRY TESTS +// ============================================================================ + +describe('Error Handling and Retry Logic', () => { + const REQUEST_TIMEOUT_MS = 30000; + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 1000; + + describe('timeout configuration', () => { + it('should have 30 second timeout', () => { + expect(REQUEST_TIMEOUT_MS).toBe(30000); + }); + + it('should allow 3 retry attempts', () => { + expect(MAX_RETRIES).toBe(3); + }); + + it('should have 1 second base retry delay', () => { + expect(RETRY_DELAY_MS).toBe(1000); + }); + }); + + describe('exponential backoff', () => { + function calculateBackoffDelay(attempt: number): number { + return RETRY_DELAY_MS * attempt; + } + + it('should calculate correct delay for attempt 1', () => { + expect(calculateBackoffDelay(1)).toBe(1000); + }); + + it('should calculate correct delay for attempt 2', () => { + expect(calculateBackoffDelay(2)).toBe(2000); + }); + + it('should calculate correct delay for attempt 3', () => { + expect(calculateBackoffDelay(3)).toBe(3000); + }); + }); + + describe('retry decision logic', () => { + function shouldRetry(attempt: number, error: Error): boolean { + // Don't retry if we've exhausted attempts + if (attempt >= MAX_RETRIES) return false; + + // Retry on network errors + if (error.name === 'AbortError') return true; + if (error.message.includes('ECONNREFUSED')) return true; + if (error.message.includes('ETIMEDOUT')) return true; + + // Don't retry on validation errors + if (error.name === 'ZodError') return false; + + // Retry on other errors + return true; + } + + it('should retry on timeout (AbortError)', () => { + const error = new Error('Timeout'); + error.name = 'AbortError'; + expect(shouldRetry(1, error)).toBe(true); + }); + + it('should retry on connection refused', () => { + const error = new Error('ECONNREFUSED'); + expect(shouldRetry(1, error)).toBe(true); + }); + + it('should not retry on Zod validation error', () => { + const error = new Error('Invalid data'); + error.name = 'ZodError'; + expect(shouldRetry(1, error)).toBe(false); + }); + + it('should not retry after max attempts', () => { + const error = new Error('Network error'); + error.name = 'AbortError'; + expect(shouldRetry(3, error)).toBe(false); + }); + }); + + describe('error result generation', () => { + interface ImportResult { + success: boolean; + imported: number; + skipped: number; + errors: number; + message: string; + } + + function createErrorResult(message: string): ImportResult { + return { + success: false, + imported: 0, + skipped: 0, + errors: 1, + message, + }; + } + + it('should create error result with message', () => { + const result = createErrorResult('Connection failed after 3 retries'); + expect(result.success).toBe(false); + expect(result.errors).toBe(1); + expect(result.message).toContain('Connection failed'); + }); + + it('should indicate zero imports on error', () => { + const result = createErrorResult('Timeout'); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(0); + }); + }); +}); + +// ============================================================================ +// IMPORT RESULT TESTS +// ============================================================================ + +describe('Import Result Generation', () => { + interface SkippedUserInfo { + tautulliUserId: number; + username: string; + recordCount: number; + } + + interface ImportResult { + success: boolean; + imported: number; + skipped: number; + errors: number; + message: string; + skippedUsers?: SkippedUserInfo[]; + } + + function createSuccessResult( + imported: number, + skipped: number, + errors: number, + skippedUsers?: Map + ): ImportResult { + const result: ImportResult = { + success: true, + imported, + skipped, + errors, + message: `Imported ${imported} sessions, skipped ${skipped}, ${errors} errors`, + }; + + if (skippedUsers && skippedUsers.size > 0) { + result.skippedUsers = Array.from(skippedUsers.entries()).map(([userId, info]) => ({ + tautulliUserId: userId, + username: info.username, + recordCount: info.count, + })); + } + + return result; + } + + it('should create success result with counts', () => { + const result = createSuccessResult(100, 5, 2); + expect(result.success).toBe(true); + expect(result.imported).toBe(100); + expect(result.skipped).toBe(5); + expect(result.errors).toBe(2); + }); + + it('should include skipped users when present', () => { + const skippedUsers = new Map(); + skippedUsers.set(374704766, { username: 'Luke Lino', count: 15 }); + skippedUsers.set(3453396, { username: 'Ryan Weaver', count: 3 }); + + const result = createSuccessResult(100, 18, 0, skippedUsers); + + expect(result.skippedUsers).toBeDefined(); + expect(result.skippedUsers).toHaveLength(2); + expect(result.skippedUsers?.[0]).toEqual({ + tautulliUserId: 374704766, + username: 'Luke Lino', + recordCount: 15, + }); + }); + + it('should not include skippedUsers when empty', () => { + const skippedUsers = new Map(); + const result = createSuccessResult(100, 0, 0, skippedUsers); + expect(result.skippedUsers).toBeUndefined(); + }); + + it('should generate appropriate message', () => { + const result = createSuccessResult(500, 50, 10); + expect(result.message).toBe('Imported 500 sessions, skipped 50, 10 errors'); + }); +}); + +// ============================================================================ +// CHANGE DETECTION TESTS +// ============================================================================ + +describe('Change Detection Logic', () => { + interface ExistingSession { + id: string; + stoppedAt: Date | null; + durationMs: number | null; + pausedDurationMs: number; + watched: boolean; + } + + interface TautulliRecord { + stopped: number; + duration: number; + paused_counter: number; + watched_status: number; + } + + function hasChanges(existing: ExistingSession, record: TautulliRecord): boolean { + const newStoppedAt = new Date(record.stopped * 1000); + const newDurationMs = record.duration * 1000; + const newPausedDurationMs = record.paused_counter * 1000; + const newWatched = record.watched_status === 1; + + const stoppedAtChanged = existing.stoppedAt?.getTime() !== newStoppedAt.getTime(); + const durationChanged = existing.durationMs !== newDurationMs; + const pausedChanged = existing.pausedDurationMs !== newPausedDurationMs; + const watchedChanged = existing.watched !== newWatched; + + return stoppedAtChanged || durationChanged || pausedChanged || watchedChanged; + } + + it('should detect no changes when values match', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 0, + watched_status: 1, + }; + + expect(hasChanges(existing, record)).toBe(false); + }); + + it('should detect stoppedAt change', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494420, // 2 seconds later + duration: 6292, + paused_counter: 0, + watched_status: 1, + }; + + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should detect duration change', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6300, // Different duration + paused_counter: 0, + watched_status: 1, + }; + + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should detect paused duration change', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 100, // Was paused + watched_status: 1, + }; + + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should detect watched status change', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: false, // Not watched + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 0, + watched_status: 1, // Now watched + }; + + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should handle null stoppedAt in existing session', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: null, // Session was never stopped (active) + durationMs: 6292000, + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 0, + watched_status: 1, + }; + + // null.getTime() would throw, so this checks the comparison handles it + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should handle null durationMs in existing session', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: null, // Duration not yet recorded + pausedDurationMs: 0, + watched: true, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 0, + watched_status: 1, + }; + + expect(hasChanges(existing, record)).toBe(true); + }); + + it('should treat watched_status < 1 as not watched', () => { + const existing: ExistingSession = { + id: 'uuid-1', + stoppedAt: new Date(1764494418 * 1000), + durationMs: 6292000, + pausedDurationMs: 0, + watched: false, + }; + + const record: TautulliRecord = { + stopped: 1764494418, + duration: 6292, + paused_counter: 0, + watched_status: 0.75, // Partial watch - still not "watched" + }; + + expect(hasChanges(existing, record)).toBe(false); + }); +}); diff --git a/apps/server/src/services/__tests__/userService.test.ts b/apps/server/src/services/__tests__/userService.test.ts new file mode 100644 index 0000000..b396df9 --- /dev/null +++ b/apps/server/src/services/__tests__/userService.test.ts @@ -0,0 +1,677 @@ +/** + * User Service Tests + * + * Tests for the userService module that centralizes user operations. + * Uses mocked database to test business logic in isolation. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; + +// Mock the database +vi.mock('../../db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, +})); + +// Import after mocking +import { db } from '../../db/client.js'; +import { + getUserById, + requireUserById, + getServerUserByExternalId, + getUserByPlexAccountId, + getUserByUsername, + getOwnerUser, + getServerUserWithDetails, + getUserWithStats, + createOwnerUser, + linkPlexAccount, + syncUserFromMediaServer, + updateServerUserTrustScore, + getServerUsersByServer, + batchSyncUsersFromMediaServer, + UserNotFoundError, + ServerUserNotFoundError, +} from '../userService.js'; + +// Helper to create mock user (identity layer) +function createMockUser(overrides: Record = {}) { + return { + id: randomUUID(), + username: 'testuser', + name: null, + email: 'test@example.com', + thumbnail: null, + passwordHash: null, + plexAccountId: null, + role: 'member', + aggregateTrustScore: 100, + totalViolations: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// Helper to create mock server user (account on specific server) +function createMockServerUser(overrides: Record = {}) { + return { + id: randomUUID(), + userId: randomUUID(), + serverId: randomUUID(), + externalId: 'external-123', + username: 'serveruser', + email: null, + thumbUrl: null, + isServerAdmin: false, + trustScore: 100, + sessionCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// Helper to setup select chain mock +function mockSelectChain(result: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(result), + orderBy: vi.fn().mockReturnThis(), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + return chain; +} + +// Helper to setup update chain mock +function mockUpdateChain(result: unknown[]) { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue(result), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getUserById', () => { + it('should return user when found', async () => { + const mockUser = createMockUser(); + mockSelectChain([mockUser]); + + const result = await getUserById(mockUser.id); + + expect(result).toEqual(mockUser); + expect(db.select).toHaveBeenCalled(); + }); + + it('should return null when user not found', async () => { + mockSelectChain([]); + + const result = await getUserById('non-existent-id'); + + expect(result).toBeNull(); + }); +}); + +describe('requireUserById', () => { + it('should return user when found', async () => { + const mockUser = createMockUser(); + mockSelectChain([mockUser]); + + const result = await requireUserById(mockUser.id); + + expect(result).toEqual(mockUser); + }); + + it('should throw UserNotFoundError when user not found', async () => { + mockSelectChain([]); + + await expect(requireUserById('non-existent-id')).rejects.toThrow(UserNotFoundError); + await expect(requireUserById('non-existent-id')).rejects.toThrow( + "User with ID 'non-existent-id' not found" + ); + }); +}); + +describe('getServerUserByExternalId', () => { + it('should return server user when found by serverId and externalId', async () => { + const mockServerUser = createMockServerUser(); + mockSelectChain([mockServerUser]); + + const result = await getServerUserByExternalId(mockServerUser.serverId as string, 'external-123'); + + expect(result).toEqual(mockServerUser); + }); + + it('should return null when not found', async () => { + mockSelectChain([]); + + const result = await getServerUserByExternalId('server-id', 'non-existent'); + + expect(result).toBeNull(); + }); +}); + +describe('getUserByPlexAccountId', () => { + it('should return user when found by Plex account ID', async () => { + const mockUser = createMockUser({ plexAccountId: 'plex-123' }); + mockSelectChain([mockUser]); + + const result = await getUserByPlexAccountId('plex-123'); + + expect(result).toEqual(mockUser); + }); + + it('should return null when not found', async () => { + mockSelectChain([]); + + const result = await getUserByPlexAccountId('non-existent'); + + expect(result).toBeNull(); + }); +}); + +describe('getUserByUsername', () => { + it('should return user when found by username', async () => { + const mockUser = createMockUser({ username: 'johndoe' }); + mockSelectChain([mockUser]); + + const result = await getUserByUsername('johndoe'); + + expect(result).toEqual(mockUser); + }); + + it('should return null when not found', async () => { + mockSelectChain([]); + + const result = await getUserByUsername('nonexistent'); + + expect(result).toBeNull(); + }); +}); + +describe('getOwnerUser', () => { + it('should return owner user when exists', async () => { + const mockOwner = createMockUser({ role: 'owner' }); + mockSelectChain([mockOwner]); + + const result = await getOwnerUser(); + + expect(result).toEqual(mockOwner); + expect(result?.role).toBe('owner'); + }); + + it('should return null when no owner exists', async () => { + mockSelectChain([]); + + const result = await getOwnerUser(); + + expect(result).toBeNull(); + }); +}); + +describe('getServerUserWithDetails', () => { + it('should return server user with details when found', async () => { + const serverUserId = randomUUID(); + const userId = randomUUID(); + const serverId = randomUUID(); + const serverUserWithDetails = { + id: serverUserId, + userId, + serverId, + externalId: 'ext-123', + username: 'testuser', + email: 'test@example.com', + thumbUrl: null, + isServerAdmin: false, + trustScore: 100, + sessionCount: 5, + createdAt: new Date(), + updatedAt: new Date(), + userName: 'Test User', + userThumbnail: null, + userEmail: 'test@example.com', + userRole: 'member', + userAggregateTrustScore: 100, + serverName: 'My Plex Server', + serverType: 'plex', + }; + + // Mock the join query chain + const chain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([serverUserWithDetails]), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + + const result = await getServerUserWithDetails(serverUserId); + + expect(result).toBeDefined(); + expect(result?.server.name).toBe('My Plex Server'); + }); + + it('should return null when server user not found', async () => { + const chain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + + const result = await getServerUserWithDetails('non-existent'); + + expect(result).toBeNull(); + }); +}); + +describe('getUserWithStats', () => { + it('should return user with stats when found', async () => { + const userId = randomUUID(); + const serverUserId = randomUUID(); + const serverId = randomUUID(); + const mockUser = createMockUser({ id: userId }); + const serverUserRow = { + id: serverUserId, + serverId, + serverName: 'My Server', + serverType: 'plex', + username: 'testuser', + thumbUrl: null, + trustScore: 100, + sessionCount: 5, + }; + const stats = { totalSessions: 42, totalWatchTime: BigInt(3600000) }; + + // 1. getUserById - returns identity user + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([mockUser]), + } as never); + + // 2. Get server users with join - returns array (no limit) + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([serverUserRow]), + } as never); + + // 3. Get stats from sessions + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([stats]), + } as never); + + const result = await getUserWithStats(userId); + + expect(result).not.toBeNull(); + expect(result?.stats.totalSessions).toBe(42); + expect(result?.stats.totalWatchTime).toBe(3600000); + expect(result?.serverUsers).toHaveLength(1); + }); + + it('should return null when user not found', async () => { + // getUserById returns null + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + } as never); + + const result = await getUserWithStats('non-existent'); + + expect(result).toBeNull(); + }); + + it('should handle zero stats when user has no server accounts', async () => { + const userId = randomUUID(); + const mockUser = createMockUser({ id: userId }); + + // 1. getUserById returns user + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([mockUser]), + } as never); + + // 2. No server users + vi.mocked(db.select).mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + } as never); + + // Stats query is not called when no server users + + const result = await getUserWithStats(userId); + + expect(result?.stats.totalSessions).toBe(0); + expect(result?.stats.totalWatchTime).toBe(0); + expect(result?.serverUsers).toHaveLength(0); + }); +}); + +describe('createOwnerUser', () => { + it('should create owner user with password', async () => { + const ownerUser = createMockUser({ + username: 'admin', + role: 'owner', + passwordHash: 'hashed-password', + }); + + const chain = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([ownerUser]), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + + const result = await createOwnerUser({ + username: 'admin', + passwordHash: 'hashed-password', + }); + + expect(result.role).toBe('owner'); + expect(result.username).toBe('admin'); + expect(db.insert).toHaveBeenCalled(); + }); + + it('should create owner user with Plex account', async () => { + const ownerUser = createMockUser({ + username: 'plexadmin', + role: 'owner', + plexAccountId: 'plex-12345', + thumbUrl: 'https://plex.tv/avatar.jpg', + }); + + const chain = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([ownerUser]), + }; + vi.mocked(db.insert).mockReturnValue(chain as never); + + const result = await createOwnerUser({ + username: 'plexadmin', + plexAccountId: 'plex-12345', + thumbnail: 'https://plex.tv/avatar.jpg', + }); + + expect(result.role).toBe('owner'); + expect(result.plexAccountId).toBe('plex-12345'); + }); +}); + +describe('linkPlexAccount', () => { + it('should link Plex account to existing user', async () => { + const userId = randomUUID(); + const updatedUser = createMockUser({ + id: userId, + plexAccountId: 'plex-linked', + thumbnail: 'https://plex.tv/thumb.jpg', + }); + + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([updatedUser]), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + + const result = await linkPlexAccount(userId, 'plex-linked', 'https://plex.tv/thumb.jpg'); + + expect(result.plexAccountId).toBe('plex-linked'); + expect(result.thumbnail).toBe('https://plex.tv/thumb.jpg'); + }); + + it('should link Plex account without thumb', async () => { + const userId = randomUUID(); + const updatedUser = createMockUser({ + id: userId, + plexAccountId: 'plex-no-thumb', + }); + + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([updatedUser]), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + + const result = await linkPlexAccount(userId, 'plex-no-thumb'); + + expect(result.plexAccountId).toBe('plex-no-thumb'); + }); + + it('should throw UserNotFoundError when user not found', async () => { + const chain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([]), + }; + vi.mocked(db.update).mockReturnValue(chain as never); + + await expect(linkPlexAccount('non-existent', 'plex-123')).rejects.toThrow(UserNotFoundError); + }); +}); + +describe('syncUserFromMediaServer', () => { + const serverId = randomUUID(); + const mediaUser = { + id: 'external-456', + username: 'plexuser', + email: 'plex@example.com', + thumb: 'https://plex.tv/thumb.jpg', + isAdmin: false, + }; + + it('should create new server user when not exists', async () => { + const now = new Date(); + const userId = randomUUID(); + const newServerUser = createMockServerUser({ + externalId: mediaUser.id, + username: mediaUser.username, + serverId, + userId, + createdAt: now, + updatedAt: now, + }); + const newUser = createMockUser({ + id: userId, + username: mediaUser.username, + }); + + // First: check for existing server user (returns empty) - before transaction + const selectChain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + vi.mocked(db.select).mockReturnValue(selectChain as never); + + // Mock transaction - passes tx object to callback and returns result + vi.mocked(db.transaction).mockImplementation(async (callback) => { + // Create mock tx with select and insert methods + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing user by email + }), + insert: vi.fn() + .mockReturnValueOnce({ + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([newUser]), // Create user + }) + .mockReturnValueOnce({ + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([newServerUser]), // Create server user + }), + }; + return callback(tx as never); + }); + + const result = await syncUserFromMediaServer(serverId, mediaUser); + + expect(result.created).toBe(true); + expect(result.serverUser.externalId).toBe(mediaUser.id); + expect(result.user.id).toBe(userId); + }); + + it('should update existing server user when exists', async () => { + const userId = randomUUID(); + const serverUserId = randomUUID(); + const existingServerUser = createMockServerUser({ + id: serverUserId, + externalId: mediaUser.id, + username: 'oldusername', + serverId, + userId, + }); + const existingUser = createMockUser({ + id: userId, + username: 'oldusername', + }); + const updatedServerUser = createMockServerUser({ + id: serverUserId, + externalId: mediaUser.id, + username: mediaUser.username, + serverId, + userId, + }); + + // First: check for existing server user (returns existing) + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ ...existingServerUser, user: existingUser }]), + } as never); + + // Update server user + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([updatedServerUser]), + } as never); + + const result = await syncUserFromMediaServer(serverId, mediaUser); + + expect(result.created).toBe(false); + expect(result.serverUser.username).toBe(mediaUser.username); + }); +}); + +describe('updateServerUserTrustScore', () => { + it('should update trust score successfully', async () => { + const serverUserId = randomUUID(); + const updatedServerUser = createMockServerUser({ id: serverUserId, trustScore: 80 }); + mockUpdateChain([updatedServerUser]); + + const result = await updateServerUserTrustScore(serverUserId, 80); + + expect(result.trustScore).toBe(80); + }); + + it('should throw ServerUserNotFoundError when server user not found', async () => { + mockUpdateChain([]); + + await expect(updateServerUserTrustScore('non-existent', 50)).rejects.toThrow(ServerUserNotFoundError); + }); +}); + +describe('getServerUsersByServer', () => { + it('should return map of server users by externalId', async () => { + const serverId = randomUUID(); + const serverUsers = [ + createMockServerUser({ externalId: 'ext-1', username: 'user1', serverId }), + createMockServerUser({ externalId: 'ext-2', username: 'user2', serverId }), + createMockServerUser({ externalId: 'ext-3', username: 'user3', serverId }), + ]; + + // Setup select to return array directly (no limit) + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(serverUsers), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + + const result = await getServerUsersByServer(serverId); + + expect(result.size).toBe(3); + expect(result.get('ext-1')?.username).toBe('user1'); + expect(result.get('ext-2')?.username).toBe('user2'); + expect(result.get('ext-3')?.username).toBe('user3'); + }); + + it('should return empty map when no server users', async () => { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + vi.mocked(db.select).mockReturnValue(chain as never); + + const result = await getServerUsersByServer(randomUUID()); + + expect(result.size).toBe(0); + }); +}); + +describe('batchSyncUsersFromMediaServer', () => { + it('should return zeros for empty input', async () => { + const result = await batchSyncUsersFromMediaServer(randomUUID(), []); + + expect(result).toEqual({ added: 0, updated: 0 }); + expect(db.insert).not.toHaveBeenCalled(); + }); + + // Full sync behavior is tested in test/integration/userService.integration.test.ts + // Unit tests for the loop logic are covered by syncUserFromMediaServer tests above +}); + +describe('UserNotFoundError', () => { + it('should be instanceof Error', () => { + const error = new UserNotFoundError('test-id'); + expect(error).toBeInstanceOf(Error); + }); + + it('should have correct name', () => { + const error = new UserNotFoundError('test-id'); + expect(error.name).toBe('UserNotFoundError'); + }); + + it('should format message with ID', () => { + const error = new UserNotFoundError('abc-123'); + expect(error.message).toBe("User with ID 'abc-123' not found"); + }); + + it('should format message without ID', () => { + const error = new UserNotFoundError(); + expect(error.message).toBe('User not found'); + }); + + it('should have HTTP status code 404', () => { + const error = new UserNotFoundError('test'); + expect(error.statusCode).toBe(404); + }); + + it('should have error code from NotFoundError', () => { + const error = new UserNotFoundError('test'); + expect(error.code).toBe('RES_001'); + }); +}); diff --git a/apps/server/src/services/cache.ts b/apps/server/src/services/cache.ts new file mode 100644 index 0000000..69af6ba --- /dev/null +++ b/apps/server/src/services/cache.ts @@ -0,0 +1,532 @@ +/** + * Redis cache service for Tracearr + * Handles caching of active sessions, dashboard stats, and other frequently accessed data + */ + +import type { Redis } from 'ioredis'; +import { REDIS_KEYS, CACHE_TTL } from '@tracearr/shared'; +import type { ActiveSession, DashboardStats } from '@tracearr/shared'; + +export interface CacheService { + // Active sessions (legacy - JSON array, deprecated) + getActiveSessions(): Promise; + setActiveSessions(sessions: ActiveSession[]): Promise; + + // Active sessions (atomic SET-based operations) + addActiveSession(session: ActiveSession): Promise; + removeActiveSession(sessionId: string): Promise; + getActiveSessionIds(): Promise; + getAllActiveSessions(): Promise; + updateActiveSession(session: ActiveSession): Promise; + syncActiveSessions(sessions: ActiveSession[]): Promise; + incrementalSyncActiveSessions( + newSessions: ActiveSession[], + stoppedSessionIds: string[], + updatedSessions: ActiveSession[] + ): Promise; + + // Dashboard stats + getDashboardStats(): Promise; + setDashboardStats(stats: DashboardStats): Promise; + + // Session by ID + getSessionById(id: string): Promise; + setSessionById(id: string, session: ActiveSession): Promise; + deleteSessionById(id: string): Promise; + + // User sessions + getUserSessions(userId: string): Promise; + addUserSession(userId: string, sessionId: string): Promise; + removeUserSession(userId: string, sessionId: string): Promise; + + // Server health tracking + getServerHealth(serverId: string): Promise; + setServerHealth(serverId: string, isHealthy: boolean): Promise; + + // Generic cache operations + invalidateCache(key: string): Promise; + invalidatePattern(pattern: string): Promise; + + // Health check + ping(): Promise; +} + +export function createCacheService(redis: Redis): CacheService { + const service: CacheService = { + // Active sessions + async getActiveSessions(): Promise { + const data = await redis.get(REDIS_KEYS.ACTIVE_SESSIONS); + if (!data) return null; + try { + return JSON.parse(data) as ActiveSession[]; + } catch { + return null; + } + }, + + async setActiveSessions(sessions: ActiveSession[]): Promise { + await redis.setex( + REDIS_KEYS.ACTIVE_SESSIONS, + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(sessions) + ); + // Invalidate dashboard stats so they reflect the new session count + await redis.del(REDIS_KEYS.DASHBOARD_STATS); + }, + + // Atomic SET-based operations for active sessions + async addActiveSession(session: ActiveSession): Promise { + const pipeline = redis.multi(); + // Add session ID to the active sessions SET + pipeline.sadd(REDIS_KEYS.ACTIVE_SESSION_IDS, session.id); + // Set TTL on the SET (refreshed on each add) + pipeline.expire(REDIS_KEYS.ACTIVE_SESSION_IDS, CACHE_TTL.ACTIVE_SESSIONS); + // Store session data + pipeline.setex( + REDIS_KEYS.SESSION_BY_ID(session.id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + // Invalidate dashboard stats atomically with session add + pipeline.del(REDIS_KEYS.DASHBOARD_STATS); + const results = await pipeline.exec(); + if (!results || results.some(([err]) => err !== null)) { + console.error('[Cache] addActiveSession pipeline failed:', results); + } + }, + + async removeActiveSession(sessionId: string): Promise { + const pipeline = redis.multi(); + // Remove from active sessions SET (atomic) + pipeline.srem(REDIS_KEYS.ACTIVE_SESSION_IDS, sessionId); + // Remove session data + pipeline.del(REDIS_KEYS.SESSION_BY_ID(sessionId)); + // Invalidate dashboard stats atomically with session remove + pipeline.del(REDIS_KEYS.DASHBOARD_STATS); + const results = await pipeline.exec(); + if (!results || results.some(([err]) => err !== null)) { + console.error('[Cache] removeActiveSession pipeline failed:', results); + } + }, + + async getActiveSessionIds(): Promise { + return await redis.smembers(REDIS_KEYS.ACTIVE_SESSION_IDS); + }, + + async getAllActiveSessions(): Promise { + const ids = await redis.smembers(REDIS_KEYS.ACTIVE_SESSION_IDS); + if (ids.length === 0) return []; + + // Chunk MGET calls to prevent Redis blocking with large sets + const CHUNK_SIZE = 100; + const sessions: ActiveSession[] = []; + const staleIds: string[] = []; + + for (let i = 0; i < ids.length; i += CHUNK_SIZE) { + const chunkIds = ids.slice(i, i + CHUNK_SIZE); + const keys = chunkIds.map((id) => REDIS_KEYS.SESSION_BY_ID(id)); + const data = await redis.mget(...keys); + + for (let j = 0; j < data.length; j++) { + const sessionData = data[j]; + const sessionId = chunkIds[j]!; + if (sessionData) { + try { + sessions.push(JSON.parse(sessionData) as ActiveSession); + } catch { + staleIds.push(sessionId); + } + } else { + // Double-check data doesn't exist now (prevents race with concurrent adds) + const exists = await redis.exists(REDIS_KEYS.SESSION_BY_ID(sessionId)); + if (!exists) { + staleIds.push(sessionId); + } + } + } + } + + // Batch cleanup stale IDs + if (staleIds.length > 0) { + await redis.srem(REDIS_KEYS.ACTIVE_SESSION_IDS, ...staleIds); + } + + return sessions; + }, + + async updateActiveSession(session: ActiveSession): Promise { + // Update session data and refresh SET TTL to prevent stale ID accumulation + const pipeline = redis.multi(); + pipeline.setex( + REDIS_KEYS.SESSION_BY_ID(session.id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + // Refresh SET TTL to match session data TTL + pipeline.expire(REDIS_KEYS.ACTIVE_SESSION_IDS, CACHE_TTL.ACTIVE_SESSIONS); + const results = await pipeline.exec(); + if (!results || results.some(([err]) => err !== null)) { + console.error('[Cache] updateActiveSession pipeline failed:', results); + } + }, + + async syncActiveSessions(sessions: ActiveSession[]): Promise { + // Full sync: replace all active sessions atomically + const pipeline = redis.multi(); + + // Delete old SET + pipeline.del(REDIS_KEYS.ACTIVE_SESSION_IDS); + + if (sessions.length > 0) { + // Add all session IDs to SET + pipeline.sadd(REDIS_KEYS.ACTIVE_SESSION_IDS, ...sessions.map((s) => s.id)); + pipeline.expire(REDIS_KEYS.ACTIVE_SESSION_IDS, CACHE_TTL.ACTIVE_SESSIONS); + + // Store each session's data + for (const session of sessions) { + pipeline.setex( + REDIS_KEYS.SESSION_BY_ID(session.id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + } + } + + // Invalidate dashboard stats atomically + pipeline.del(REDIS_KEYS.DASHBOARD_STATS); + const results = await pipeline.exec(); + if (!results || results.some(([err]) => err !== null)) { + console.error('[Cache] syncActiveSessions pipeline failed:', results); + } + }, + + async incrementalSyncActiveSessions( + newSessions: ActiveSession[], + stoppedSessionIds: string[], + updatedSessions: ActiveSession[] + ): Promise { + const hasChanges = newSessions.length > 0 || stoppedSessionIds.length > 0 || updatedSessions.length > 0; + if (!hasChanges) return; + + const pipeline = redis.multi(); + + // Add new sessions to SET and store their data + for (const session of newSessions) { + pipeline.sadd(REDIS_KEYS.ACTIVE_SESSION_IDS, session.id); + pipeline.setex( + REDIS_KEYS.SESSION_BY_ID(session.id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + } + + // Remove stopped sessions from SET and delete their data + for (const sessionId of stoppedSessionIds) { + pipeline.srem(REDIS_KEYS.ACTIVE_SESSION_IDS, sessionId); + pipeline.del(REDIS_KEYS.SESSION_BY_ID(sessionId)); + } + + // Update existing session data (already in SET) + for (const session of updatedSessions) { + pipeline.setex( + REDIS_KEYS.SESSION_BY_ID(session.id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + } + + // Refresh SET TTL if we have active sessions + if (newSessions.length > 0 || updatedSessions.length > 0) { + pipeline.expire(REDIS_KEYS.ACTIVE_SESSION_IDS, CACHE_TTL.ACTIVE_SESSIONS); + } + + // Invalidate dashboard stats atomically + pipeline.del(REDIS_KEYS.DASHBOARD_STATS); + const results = await pipeline.exec(); + if (!results || results.some(([err]) => err !== null)) { + console.error('[Cache] incrementalSyncActiveSessions pipeline failed:', results); + } + }, + + // Dashboard stats + async getDashboardStats(): Promise { + const data = await redis.get(REDIS_KEYS.DASHBOARD_STATS); + if (!data) return null; + try { + return JSON.parse(data) as DashboardStats; + } catch { + return null; + } + }, + + async setDashboardStats(stats: DashboardStats): Promise { + await redis.setex( + REDIS_KEYS.DASHBOARD_STATS, + CACHE_TTL.DASHBOARD_STATS, + JSON.stringify(stats) + ); + }, + + // Session by ID + async getSessionById(id: string): Promise { + const data = await redis.get(REDIS_KEYS.SESSION_BY_ID(id)); + if (!data) return null; + try { + return JSON.parse(data) as ActiveSession; + } catch { + return null; + } + }, + + async setSessionById(id: string, session: ActiveSession): Promise { + await redis.setex( + REDIS_KEYS.SESSION_BY_ID(id), + CACHE_TTL.ACTIVE_SESSIONS, + JSON.stringify(session) + ); + }, + + async deleteSessionById(id: string): Promise { + await redis.del(REDIS_KEYS.SESSION_BY_ID(id)); + }, + + // User sessions (set of session IDs for a user) + async getUserSessions(userId: string): Promise { + const data = await redis.smembers(REDIS_KEYS.USER_SESSIONS(userId)); + if (!data || data.length === 0) return null; + return data; + }, + + async addUserSession(userId: string, sessionId: string): Promise { + const key = REDIS_KEYS.USER_SESSIONS(userId); + await redis.sadd(key, sessionId); + await redis.expire(key, CACHE_TTL.USER_SESSIONS); + }, + + async removeUserSession(userId: string, sessionId: string): Promise { + await redis.srem(REDIS_KEYS.USER_SESSIONS(userId), sessionId); + }, + + // Server health tracking + async getServerHealth(serverId: string): Promise { + const data = await redis.get(REDIS_KEYS.SERVER_HEALTH(serverId)); + if (data === null) return null; + return data === 'true'; + }, + + async setServerHealth(serverId: string, isHealthy: boolean): Promise { + await redis.setex( + REDIS_KEYS.SERVER_HEALTH(serverId), + CACHE_TTL.SERVER_HEALTH, + isHealthy ? 'true' : 'false' + ); + }, + + // Generic cache operations + async invalidateCache(key: string): Promise { + await redis.del(key); + }, + + async invalidatePattern(pattern: string): Promise { + const keys = await redis.keys(pattern); + if (keys.length > 0) { + await redis.del(...keys); + } + }, + + // Health check + async ping(): Promise { + try { + const result = await redis.ping(); + return result === 'PONG'; + } catch { + return false; + } + }, + }; + + // Store instance for global access + cacheServiceInstance = service; + + return service; +} + +// Pub/Sub helper functions for real-time events +export interface PubSubService { + publish(event: string, data: unknown): Promise; + subscribe(channel: string, callback: (message: string) => void): Promise; + unsubscribe(channel: string): Promise; +} + +// Module-level storage for service instances +let pubSubServiceInstance: PubSubService | null = null; +let cacheServiceInstance: CacheService | null = null; + +/** + * Get the global PubSub service instance + * Must be called after createPubSubService has been called + */ +export function getPubSubService(): PubSubService | null { + return pubSubServiceInstance; +} + +/** + * Get the global Cache service instance + * Must be called after createCacheService has been called + */ +export function getCacheService(): CacheService | null { + return cacheServiceInstance; +} + +export function createPubSubService( + publisher: Redis, + subscriber: Redis +): PubSubService { + const callbacks = new Map void>(); + + subscriber.on('message', (channel: string, message: string) => { + const callback = callbacks.get(channel); + if (callback) { + callback(message); + } + }); + + const service: PubSubService = { + async publish(event: string, data: unknown): Promise { + await publisher.publish( + REDIS_KEYS.PUBSUB_EVENTS, + JSON.stringify({ event, data, timestamp: Date.now() }) + ); + }, + + async subscribe( + channel: string, + callback: (message: string) => void + ): Promise { + callbacks.set(channel, callback); + await subscriber.subscribe(channel); + }, + + async unsubscribe(channel: string): Promise { + callbacks.delete(channel); + await subscriber.unsubscribe(channel); + }, + }; + + // Store instance for global access + pubSubServiceInstance = service; + + return service; +} + +// ============================================================================ +// Atomic Cache Helpers +// ============================================================================ + +/** + * Execute DB operation with cache invalidation. + * Pattern: Invalidate → Execute → Invalidate again + * Prevents stale reads during and after operation. + * + * @param redis - Redis client + * @param keysToInvalidate - Cache keys to invalidate + * @param operation - Async operation to execute + * @returns Result of the operation + * + * @example + * await withCacheInvalidation(redis, [REDIS_KEYS.ACTIVE_SESSIONS], async () => { + * return await db.insert(sessions).values(data); + * }); + */ +export async function withCacheInvalidation( + redis: Redis, + keysToInvalidate: string[], + operation: () => Promise +): Promise { + // Invalidate before (prevents stale reads during operation) + if (keysToInvalidate.length > 0) { + await redis.del(...keysToInvalidate); + } + + // Execute the operation + const result = await operation(); + + // Invalidate after (catches concurrent writes) + if (keysToInvalidate.length > 0) { + await redis.del(...keysToInvalidate); + } + + return result; +} + +/** + * Atomic cache update with distributed lock. + * Prevents race conditions when multiple processes update same key. + * + * @param redis - Redis client + * @param key - Cache key to update + * @param ttl - TTL in seconds + * @param getData - Async function to get fresh data + * @returns Cached or fresh data + * + * @example + * const stats = await atomicCacheUpdate(redis, 'dashboard:stats', 60, async () => { + * return await computeDashboardStats(); + * }); + */ +export async function atomicCacheUpdate( + redis: Redis, + key: string, + ttl: number, + getData: () => Promise +): Promise { + const lockKey = `${key}:lock`; + + // Try to acquire lock (5 second expiry) + const lockAcquired = await redis.set(lockKey, '1', 'EX', 5, 'NX'); + + if (!lockAcquired) { + // Another process is updating, wait and read cached value + await new Promise((resolve) => setTimeout(resolve, 100)); + const cached = await redis.get(key); + if (cached) { + return JSON.parse(cached) as T; + } + // Cache miss, try again (recursive call) + return atomicCacheUpdate(redis, key, ttl, getData); + } + + try { + const data = await getData(); + await redis.setex(key, ttl, JSON.stringify(data)); + return data; + } finally { + await redis.del(lockKey); + } +} + +/** + * Atomic multi-key update using Redis MULTI/EXEC. + * All updates succeed or fail together. + * + * @param redis - Redis client + * @param updates - Array of key/value/ttl updates + * + * @example + * await atomicMultiUpdate(redis, [ + * { key: REDIS_KEYS.ACTIVE_SESSIONS, value: sessions, ttl: 300 }, + * { key: REDIS_KEYS.DASHBOARD_STATS, value: stats, ttl: 60 }, + * ]); + */ +export async function atomicMultiUpdate( + redis: Redis, + updates: Array<{ key: string; value: unknown; ttl: number }> +): Promise { + const pipeline = redis.multi(); + + for (const { key, value, ttl } of updates) { + pipeline.setex(key, ttl, JSON.stringify(value)); + } + + await pipeline.exec(); +} diff --git a/apps/server/src/services/geoip.ts b/apps/server/src/services/geoip.ts new file mode 100644 index 0000000..d3eed48 --- /dev/null +++ b/apps/server/src/services/geoip.ts @@ -0,0 +1,222 @@ +/** + * GeoIP lookup service using MaxMind GeoLite2 database + */ + +import maxmind, { type CityResponse, type Reader } from 'maxmind'; +import { GEOIP_CONFIG } from '@tracearr/shared'; + +export interface GeoLocation { + city: string | null; + region: string | null; // State/province/subdivision + country: string | null; + countryCode: string | null; + lat: number | null; + lon: number | null; +} + +const NULL_LOCATION: GeoLocation = { + city: null, + region: null, + country: null, + countryCode: null, + lat: null, + lon: null, +}; + +const LOCAL_LOCATION: GeoLocation = { + city: 'Local', + region: null, + country: 'Local Network', + countryCode: null, + lat: null, + lon: null, +}; + +export class GeoIPService { + private reader: Reader | null = null; + private initialized = false; + + async initialize(dbPath: string): Promise { + try { + this.reader = await maxmind.open(dbPath); + this.initialized = true; + } catch (error) { + // Graceful degradation - service works without GeoIP database + console.warn( + `GeoIP database not loaded: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + this.reader = null; + this.initialized = true; + } + } + + isInitialized(): boolean { + return this.initialized; + } + + hasDatabase(): boolean { + return this.reader !== null; + } + + lookup(ip: string): GeoLocation { + // Return "Local" for private/local network IPs + if (this.isPrivateIP(ip)) { + return LOCAL_LOCATION; + } + + // Return null location if no database loaded + if (!this.reader) { + return NULL_LOCATION; + } + + try { + const result = this.reader.get(ip); + + if (!result) { + return NULL_LOCATION; + } + + // Get the most specific subdivision (state/province) + // MaxMind returns subdivisions as an array, most specific last + const subdivisions = result.subdivisions; + const region = subdivisions && subdivisions.length > 0 + ? subdivisions[0]?.names?.en ?? null + : null; + + return { + city: result.city?.names?.en ?? null, + region, + country: result.country?.names?.en ?? null, + countryCode: result.country?.iso_code ?? null, + lat: result.location?.latitude ?? null, + lon: result.location?.longitude ?? null, + }; + } catch { + return NULL_LOCATION; + } + } + + isPrivateIP(ip: string): boolean { + // Handle IPv6 localhost + if (ip === '::1' || ip === '::ffff:127.0.0.1') { + return true; + } + + // Strip IPv6 prefix for mapped addresses + const cleanIp = ip.startsWith('::ffff:') ? ip.slice(7) : ip; + + // Check if it's an IPv4 address + const parts = cleanIp.split('.').map(Number); + if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) { + // Not a valid IPv4 - could be IPv6 + return this.isPrivateIPv6(ip); + } + + // 10.0.0.0 - 10.255.255.255 + if (parts[0] === 10) { + return true; + } + + // 172.16.0.0 - 172.31.255.255 + if (parts[0] === 172 && parts[1] !== undefined && parts[1] >= 16 && parts[1] <= 31) { + return true; + } + + // 192.168.0.0 - 192.168.255.255 + if (parts[0] === 192 && parts[1] === 168) { + return true; + } + + // 127.0.0.0 - 127.255.255.255 (localhost) + if (parts[0] === 127) { + return true; + } + + // 169.254.0.0 - 169.254.255.255 (link-local) + if (parts[0] === 169 && parts[1] === 254) { + return true; + } + + return false; + } + + private isPrivateIPv6(ip: string): boolean { + // Simplified check for common private IPv6 ranges + const lower = ip.toLowerCase(); + + // Loopback + if (lower === '::1') { + return true; + } + + // Link-local (fe80::/10) + if (lower.startsWith('fe80:')) { + return true; + } + + // Unique local addresses (fc00::/7) + if (lower.startsWith('fc') || lower.startsWith('fd')) { + return true; + } + + return false; + } + + /** + * Calculate the distance between two locations using the Haversine formula + * @returns Distance in kilometers, or null if coordinates are missing + */ + calculateDistance(loc1: GeoLocation, loc2: GeoLocation): number | null { + if (loc1.lat === null || loc1.lon === null || loc2.lat === null || loc2.lon === null) { + return null; + } + + const toRadians = (deg: number): number => (deg * Math.PI) / 180; + + const lat1 = toRadians(loc1.lat); + const lat2 = toRadians(loc2.lat); + const deltaLat = toRadians(loc2.lat - loc1.lat); + const deltaLon = toRadians(loc2.lon - loc1.lon); + + const a = + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return GEOIP_CONFIG.EARTH_RADIUS_KM * c; + } + + /** + * Check if travel between two locations is physically possible within a time window + * @param loc1 First location + * @param loc2 Second location + * @param timeDeltaMs Time difference in milliseconds + * @param maxSpeedKmh Maximum plausible travel speed in km/h (default: 900 for commercial aircraft) + * @returns true if travel is impossible (violation detected) + */ + isImpossibleTravel( + loc1: GeoLocation, + loc2: GeoLocation, + timeDeltaMs: number, + maxSpeedKmh: number = 900 + ): boolean { + const distance = this.calculateDistance(loc1, loc2); + + if (distance === null) { + return false; // Can't determine without coordinates + } + + const timeDeltaHours = timeDeltaMs / (1000 * 60 * 60); + + if (timeDeltaHours <= 0) { + // Same time or negative time - impossible if distance > 0 + return distance > 0; + } + + const requiredSpeedKmh = distance / timeDeltaHours; + return requiredSpeedKmh > maxSpeedKmh; + } +} + +export const geoipService = new GeoIPService(); diff --git a/apps/server/src/services/imageProxy.ts b/apps/server/src/services/imageProxy.ts new file mode 100644 index 0000000..187c420 --- /dev/null +++ b/apps/server/src/services/imageProxy.ts @@ -0,0 +1,269 @@ +/** + * Image proxy service for Plex/Jellyfin images + * + * Fetches images from media servers, resizes them, and caches to disk. + * This avoids CORS issues and reduces bandwidth to media servers. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { eq } from 'drizzle-orm'; +import { TIME_MS } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { servers } from '../db/schema.js'; +// Token encryption removed - tokens now stored in plain text (DB is localhost-only) + +// Cache directory (in project root/data/image-cache) +const CACHE_DIR = join(process.cwd(), 'data', 'image-cache'); +const CACHE_TTL_MS = TIME_MS.DAY; +const MAX_CACHE_SIZE_MB = 500; // Maximum cache size in MB + +// Ensure cache directory exists +if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); +} + +// Fallback SVG placeholders +const FALLBACK_POSTER = ` + + + + + + + No Image +`; + +const FALLBACK_AVATAR = ` + + + +`; + +const FALLBACK_ART = ` + + + + + + + +`; + +export type FallbackType = 'poster' | 'avatar' | 'art'; + +interface ProxyOptions { + serverId: string; + imagePath: string; + width?: number; + height?: number; + fallback?: FallbackType; +} + +interface ProxyResult { + data: Buffer; + contentType: string; + cached: boolean; +} + +/** + * Generate a cache key from the request parameters + */ +function getCacheKey(serverId: string, imagePath: string, width: number, height: number): string { + const hash = createHash('sha256') + .update(`${serverId}:${imagePath}:${width}:${height}`) + .digest('hex') + .slice(0, 16); + return `${hash}.webp`; +} + +/** + * Get fallback SVG buffer + */ +function getFallbackImage(type: FallbackType, _width: number, _height: number): Buffer { + let svg: string; + switch (type) { + case 'avatar': + svg = FALLBACK_AVATAR; + break; + case 'art': + svg = FALLBACK_ART; + break; + case 'poster': + default: + svg = FALLBACK_POSTER; + break; + } + + // Return the SVG resized to requested dimensions + return Buffer.from(svg); +} + +/** + * Clean up old cache files + */ +async function cleanupCache(): Promise { + try { + const files = readdirSync(CACHE_DIR); + const now = Date.now(); + let totalSize = 0; + const fileStats: Array<{ name: string; size: number; mtime: number }> = []; + + for (const file of files) { + const filePath = join(CACHE_DIR, file); + try { + const stat = statSync(filePath); + totalSize += stat.size; + fileStats.push({ name: file, size: stat.size, mtime: stat.mtimeMs }); + + // Delete files older than TTL + if (now - stat.mtimeMs > CACHE_TTL_MS) { + unlinkSync(filePath); + totalSize -= stat.size; + } + } catch { + // Ignore errors for individual files + } + } + + // If still over size limit, delete oldest files + const maxSizeBytes = MAX_CACHE_SIZE_MB * 1024 * 1024; + if (totalSize > maxSizeBytes) { + fileStats.sort((a, b) => a.mtime - b.mtime); + for (const file of fileStats) { + if (totalSize <= maxSizeBytes * 0.8) break; // Reduce to 80% of max + try { + unlinkSync(join(CACHE_DIR, file.name)); + totalSize -= file.size; + } catch { + // Ignore + } + } + } + } catch { + // Ignore cleanup errors + } +} + +// Run cleanup periodically (every hour) +setInterval(() => { void cleanupCache(); }, TIME_MS.HOUR); +// Also run on startup +void cleanupCache(); + +/** + * Fetch and proxy an image from a media server + */ +export async function proxyImage(options: ProxyOptions): Promise { + const { serverId, imagePath, width = 300, height = 450, fallback = 'poster' } = options; + + // Check cache first + const cacheKey = getCacheKey(serverId, imagePath, width, height); + const cachePath = join(CACHE_DIR, cacheKey); + + if (existsSync(cachePath)) { + try { + const stat = statSync(cachePath); + if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) { + return { + data: readFileSync(cachePath), + contentType: 'image/webp', + cached: true, + }; + } + } catch { + // Cache miss, continue to fetch + } + } + + // Look up server + const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1); + + if (!server) { + // Return fallback for missing server + const fallbackSvg = getFallbackImage(fallback, width, height); + return { + data: fallbackSvg, + contentType: 'image/svg+xml', + cached: false, + }; + } + + const token = server.token; + + // Build image URL based on server type + let imageUrl: string; + const headers: Record = {}; + + if (server.type === 'plex') { + // Plex image URLs are relative paths like /library/metadata/123/thumb/456 + // Need to append X-Plex-Token + const baseUrl = server.url.replace(/\/$/, ''); + const separator = imagePath.includes('?') ? '&' : '?'; + imageUrl = `${baseUrl}${imagePath}${separator}X-Plex-Token=${token}`; + headers['Accept'] = 'image/*'; + } else { + // Jellyfin - imagePath should include the full endpoint + const baseUrl = server.url.replace(/\/$/, ''); + imageUrl = `${baseUrl}${imagePath}`; + headers['X-Emby-Token'] = token; + headers['Accept'] = 'image/*'; + } + + // Fetch image from server + try { + const response = await fetch(imageUrl, { + headers, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const imageBuffer = Buffer.from(arrayBuffer); + + // Resize and convert to WebP using sharp + const resized = await sharp(imageBuffer) + .resize(width, height, { + fit: 'cover', + position: 'center', + }) + .webp({ quality: 80 }) + .toBuffer(); + + // Cache the result + try { + writeFileSync(cachePath, resized); + } catch { + // Cache write failure is non-fatal + } + + return { + data: resized, + contentType: 'image/webp', + cached: false, + }; + } catch { + // Return fallback on any error + const fallbackSvg = getFallbackImage(fallback, width, height); + return { + data: fallbackSvg, + contentType: 'image/svg+xml', + cached: false, + }; + } +} + +/** + * Get a gravatar URL for an email + */ +export function getGravatarUrl(email: string | null | undefined, size: number = 100): string { + if (!email) { + return ''; // Will use fallback + } + const hash = createHash('md5').update(email.toLowerCase().trim()).digest('hex'); + return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`; +} diff --git a/apps/server/src/services/mediaServer/__tests__/emby-parser.test.ts b/apps/server/src/services/mediaServer/__tests__/emby-parser.test.ts new file mode 100644 index 0000000..cef5fec --- /dev/null +++ b/apps/server/src/services/mediaServer/__tests__/emby-parser.test.ts @@ -0,0 +1,872 @@ +/** + * Emby Parser Tests + * + * Tests the pure parsing functions that convert raw Emby API responses + * into typed MediaSession, MediaUser, and MediaLibrary objects. + * + * Based on Emby OpenAPI specification v4.1.1.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + parseSession, + parseSessionsResponse, + parseUser, + parseUsersResponse, + parseLibrary, + parseLibrariesResponse, + parseWatchHistoryItem, + parseWatchHistoryResponse, + parseActivityLogItem, + parseActivityLogResponse, + parseAuthResponse, +} from '../emby/parser.js'; + +// ============================================================================ +// Session Parsing Tests +// ============================================================================ + +describe('Emby Session Parser', () => { + describe('parseSession', () => { + it('should parse a movie session', () => { + const rawSession = { + Id: 'session-123', + UserId: 'user-456', + UserName: 'John', + UserPrimaryImageTag: 'avatar-tag', + DeviceName: "John's TV", + DeviceId: 'device-uuid-789', + Client: 'Emby Web', + DeviceType: 'TV', + RemoteEndPoint: '203.0.113.50', + NowPlayingItem: { + Id: 'item-abc', + Name: 'Inception', + Type: 'Movie', + RunTimeTicks: 90000000000, // 150 minutes in ticks (10000 ticks/ms) + ProductionYear: 2010, + ImageTags: { Primary: 'poster-tag' }, + }, + PlayState: { + PositionTicks: 36000000000, // 60 minutes + IsPaused: false, + PlayMethod: 'DirectPlay', + }, + TranscodingInfo: { + Bitrate: 20000000, + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.sessionKey).toBe('session-123'); + expect(session!.mediaId).toBe('item-abc'); + expect(session!.user.id).toBe('user-456'); + expect(session!.user.username).toBe('John'); + expect(session!.user.thumb).toBe('/Users/user-456/Images/Primary'); + expect(session!.media.title).toBe('Inception'); + expect(session!.media.type).toBe('movie'); + expect(session!.media.durationMs).toBe(9000000); // 150 minutes in ms + expect(session!.media.year).toBe(2010); + expect(session!.media.thumbPath).toBe('/Items/item-abc/Images/Primary'); + expect(session!.playback.state).toBe('playing'); + expect(session!.playback.positionMs).toBe(3600000); // 60 minutes in ms + expect(session!.playback.progressPercent).toBe(40); + expect(session!.player.name).toBe("John's TV"); + expect(session!.player.deviceId).toBe('device-uuid-789'); + expect(session!.player.product).toBe('Emby Web'); + expect(session!.network.ipAddress).toBe('203.0.113.50'); + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directplay'); + }); + + it('should parse an episode session with show metadata', () => { + const rawSession = { + Id: 'session-ep', + UserId: 'user-1', + UserName: 'Jane', + DeviceName: 'iPhone', + DeviceId: 'iphone-123', + Client: 'Emby iOS', + RemoteEndPoint: '192.168.1.100', + NowPlayingItem: { + Id: 'episode-id', + Name: 'Pilot', + Type: 'Episode', + RunTimeTicks: 36000000000, // 60 minutes + SeriesName: 'Breaking Bad', + SeriesId: 'series-bb', + SeriesPrimaryImageTag: 'series-poster-tag', + ParentIndexNumber: 1, + IndexNumber: 1, + SeasonName: 'Season 1', + }, + PlayState: { + PositionTicks: 18000000000, // 30 minutes + IsPaused: true, + PlayMethod: 'Transcode', + }, + TranscodingInfo: { + Bitrate: 5000000, + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.media.type).toBe('episode'); + expect(session!.playback.state).toBe('paused'); + expect(session!.playback.progressPercent).toBe(50); + expect(session!.quality.isTranscode).toBe(true); + expect(session!.quality.videoDecision).toBe('transcode'); + expect(session!.episode).toBeDefined(); + expect(session!.episode?.showTitle).toBe('Breaking Bad'); + expect(session!.episode?.showId).toBe('series-bb'); + expect(session!.episode?.seasonNumber).toBe(1); + expect(session!.episode?.episodeNumber).toBe(1); + expect(session!.episode?.seasonName).toBe('Season 1'); + expect(session!.episode?.showThumbPath).toBe('/Items/series-bb/Images/Primary'); + }); + + it('should return null for session without NowPlayingItem', () => { + const rawSession = { + Id: 'session-idle', + UserId: 'user-1', + UserName: 'John', + // No NowPlayingItem - user is idle + }; + + const session = parseSession(rawSession); + expect(session).toBeNull(); + }); + + it('should handle missing optional fields gracefully', () => { + const rawSession = { + Id: 'minimal', + NowPlayingItem: { + Id: 'item-1', + Name: 'Test', + Type: 'Movie', + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(''); + expect(session!.user.thumb).toBeUndefined(); + expect(session!.media.durationMs).toBe(0); + expect(session!.media.thumbPath).toBeUndefined(); + expect(session!.playback.positionMs).toBe(0); + }); + + it('should get bitrate from MediaSources when no transcoding', () => { + const rawSession = { + Id: 'session-direct', + NowPlayingItem: { + Id: 'item-1', + Name: 'Movie', + Type: 'Movie', + MediaSources: [{ Bitrate: 30000000 }], // 30000000 bps = 30000 kbps + }, + PlayState: { + PlayMethod: 'DirectPlay', + }, + }; + + const session = parseSession(rawSession); + // Parser normalizes Emby bps to kbps for consistency with Plex + expect(session!.quality.bitrate).toBe(30000); + }); + + it('should prefer transcoding bitrate over source bitrate', () => { + const rawSession = { + Id: 'session-transcode', + NowPlayingItem: { + Id: 'item-1', + Name: 'Movie', + Type: 'Movie', + MediaSources: [{ Bitrate: 30000000 }], // Source: 30000 kbps + }, + PlayState: { + PlayMethod: 'Transcode', + }, + TranscodingInfo: { + Bitrate: 5000000, // Transcoding: 5000 kbps + }, + }; + + const session = parseSession(rawSession); + expect(session!.quality.bitrate).toBe(5000); // Should use transcoding bitrate + }); + }); + + describe('parseSessionsResponse', () => { + it('should filter sessions to only those with active playback', () => { + const sessions = [ + { + Id: '1', + UserId: 'u1', + NowPlayingItem: { Id: 'i1', Name: 'Playing', Type: 'Movie' }, + }, + { + Id: '2', + UserId: 'u2', + // No NowPlayingItem - idle session + }, + { + Id: '3', + UserId: 'u3', + NowPlayingItem: { Id: 'i3', Name: 'Also Playing', Type: 'Episode' }, + }, + ]; + + const parsed = parseSessionsResponse(sessions); + + expect(parsed).toHaveLength(2); + expect(parsed[0]!.sessionKey).toBe('1'); + expect(parsed[1]!.sessionKey).toBe('3'); + }); + + it('should return empty array for non-array input', () => { + expect(parseSessionsResponse(null as unknown as unknown[])).toEqual([]); + expect(parseSessionsResponse('not an array' as unknown as unknown[])).toEqual([]); + }); + }); +}); + +// ============================================================================ +// User Parsing Tests +// ============================================================================ + +describe('Emby User Parser', () => { + describe('parseUser', () => { + it('should parse user with admin policy', () => { + const rawUser = { + Id: 'admin-123', + Name: 'Administrator', + PrimaryImageTag: 'avatar-tag', + Policy: { + IsAdministrator: true, + IsDisabled: false, + }, + LastLoginDate: '2024-01-15T10:30:00.000Z', + LastActivityDate: '2024-01-15T12:45:00.000Z', + }; + + const user = parseUser(rawUser); + + expect(user.id).toBe('admin-123'); + expect(user.username).toBe('Administrator'); + expect(user.thumb).toBe('/Users/admin-123/Images/Primary'); + expect(user.isAdmin).toBe(true); + expect(user.isDisabled).toBe(false); + expect(user.lastLoginAt).toEqual(new Date('2024-01-15T10:30:00.000Z')); + expect(user.lastActivityAt).toEqual(new Date('2024-01-15T12:45:00.000Z')); + }); + + it('should parse regular user', () => { + const rawUser = { + Id: 'user-456', + Name: 'Regular User', + Policy: { + IsAdministrator: false, + IsDisabled: false, + }, + }; + + const user = parseUser(rawUser); + + expect(user.isAdmin).toBe(false); + expect(user.lastLoginAt).toBeUndefined(); + expect(user.thumb).toBeUndefined(); + }); + + it('should parse disabled user', () => { + const rawUser = { + Id: 'disabled-user', + Name: 'Disabled', + Policy: { + IsAdministrator: false, + IsDisabled: true, + }, + }; + + const user = parseUser(rawUser); + + expect(user.isDisabled).toBe(true); + }); + + it('should handle missing Policy object', () => { + const rawUser = { + Id: 'no-policy', + Name: 'Guest', + }; + + const user = parseUser(rawUser); + + expect(user.isAdmin).toBe(false); + expect(user.isDisabled).toBe(false); + }); + }); + + describe('parseUsersResponse', () => { + it('should parse array of users', () => { + const users = [ + { Id: '1', Name: 'User1', Policy: { IsAdministrator: true } }, + { Id: '2', Name: 'User2', Policy: { IsAdministrator: false } }, + ]; + + const parsed = parseUsersResponse(users); + + expect(parsed).toHaveLength(2); + expect(parsed[0]!.isAdmin).toBe(true); + expect(parsed[1]!.isAdmin).toBe(false); + }); + + it('should return empty array for non-array input', () => { + expect(parseUsersResponse(null as unknown as unknown[])).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Library Parsing Tests +// ============================================================================ + +describe('Emby Library Parser', () => { + describe('parseLibrary', () => { + it('should parse virtual folder', () => { + const rawFolder = { + ItemId: 'lib-123', + Name: 'Movies', + CollectionType: 'movies', + Locations: ['/media/movies', '/media/movies2'], + }; + + const library = parseLibrary(rawFolder); + + expect(library.id).toBe('lib-123'); + expect(library.name).toBe('Movies'); + expect(library.type).toBe('movies'); + expect(library.locations).toEqual(['/media/movies', '/media/movies2']); + }); + + it('should handle missing CollectionType', () => { + const rawFolder = { + ItemId: 'lib-456', + Name: 'Mixed Content', + }; + + const library = parseLibrary(rawFolder); + + expect(library.type).toBe('unknown'); + expect(library.locations).toEqual([]); + }); + }); + + describe('parseLibrariesResponse', () => { + it('should parse array of folders', () => { + const folders = [ + { ItemId: '1', Name: 'Movies', CollectionType: 'movies' }, + { ItemId: '2', Name: 'TV Shows', CollectionType: 'tvshows' }, + ]; + + const libraries = parseLibrariesResponse(folders); + + expect(libraries).toHaveLength(2); + expect(libraries[0]!.name).toBe('Movies'); + expect(libraries[1]!.name).toBe('TV Shows'); + }); + }); +}); + +// ============================================================================ +// Watch History Parsing Tests +// ============================================================================ + +describe('Emby Watch History Parser', () => { + describe('parseWatchHistoryItem', () => { + it('should parse movie history item', () => { + const rawItem = { + Id: 'movie-123', + Name: 'The Matrix', + Type: 'Movie', + ProductionYear: 1999, + RunTimeTicks: 81600000000, + UserData: { + PlayCount: 3, + LastPlayedDate: '2024-01-10T20:00:00.000Z', + }, + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.mediaId).toBe('movie-123'); + expect(item.title).toBe('The Matrix'); + expect(item.type).toBe('movie'); + expect(item.playCount).toBe(3); + expect(item.watchedAt).toBe('2024-01-10T20:00:00.000Z'); + expect(item.episode).toBeUndefined(); + }); + + it('should parse episode history with show metadata', () => { + const rawItem = { + Id: 'ep-456', + Name: 'Pilot', + Type: 'Episode', + SeriesName: 'Lost', + ParentIndexNumber: 1, + IndexNumber: 1, + UserData: { + PlayCount: 1, + LastPlayedDate: '2024-01-12T21:00:00.000Z', + }, + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.type).toBe('episode'); + expect(item.episode).toBeDefined(); + expect(item.episode?.showTitle).toBe('Lost'); + expect(item.episode?.seasonNumber).toBe(1); + expect(item.episode?.episodeNumber).toBe(1); + }); + + it('should handle photo type as unknown', () => { + const rawItem = { + Id: 'photo-1', + Name: 'Vacation Photo', + Type: 'Photo', + UserData: { + PlayCount: 1, + }, + }; + + const item = parseWatchHistoryItem(rawItem); + expect(item.type).toBe('unknown'); + }); + }); + + describe('parseWatchHistoryResponse', () => { + it('should parse Items from response', () => { + const response = { + Items: [ + { Id: '1', Name: 'Item 1', Type: 'Movie', UserData: { PlayCount: 1 } }, + { Id: '2', Name: 'Item 2', Type: 'Episode', SeriesName: 'Show' }, + ], + }; + + const items = parseWatchHistoryResponse(response); + + expect(items).toHaveLength(2); + expect(items[1]!.episode?.showTitle).toBe('Show'); + }); + + it('should return empty array for missing Items', () => { + expect(parseWatchHistoryResponse({})).toEqual([]); + expect(parseWatchHistoryResponse(null)).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Activity Log Parsing Tests +// ============================================================================ + +describe('Emby Activity Log Parser', () => { + describe('parseActivityLogItem', () => { + it('should parse activity entry', () => { + const rawEntry = { + Id: 12345, + Name: 'John authenticated successfully', + Overview: 'User John logged in from 192.168.1.100', + ShortOverview: 'Login successful', + Type: 'AuthenticationSucceeded', + UserId: 'user-123', + Date: '2024-01-15T10:30:00.000Z', + Severity: 'Information', + }; + + const entry = parseActivityLogItem(rawEntry); + + expect(entry.id).toBe(12345); + expect(entry.name).toBe('John authenticated successfully'); + expect(entry.type).toBe('AuthenticationSucceeded'); + expect(entry.userId).toBe('user-123'); + expect(entry.severity).toBe('Information'); + }); + + it('should handle playback activity', () => { + const rawEntry = { + Id: 67890, + Name: 'User started playing Movie', + Type: 'VideoPlayback', + ItemId: 'item-abc', + UserId: 'user-456', + Date: '2024-01-15T20:00:00.000Z', + Severity: 'Information', + }; + + const entry = parseActivityLogItem(rawEntry); + + expect(entry.type).toBe('VideoPlayback'); + expect(entry.itemId).toBe('item-abc'); + }); + }); + + describe('parseActivityLogResponse', () => { + it('should parse Items array', () => { + const response = { + Items: [ + { Id: 1, Name: 'Entry 1', Type: 'Login', Date: '2024-01-15' }, + { Id: 2, Name: 'Entry 2', Type: 'Playback', Date: '2024-01-16' }, + ], + }; + + const entries = parseActivityLogResponse(response); + + expect(entries).toHaveLength(2); + }); + }); +}); + +// ============================================================================ +// Authentication Response Parsing Tests +// ============================================================================ + +describe('Emby Auth Response Parser', () => { + describe('parseAuthResponse', () => { + it('should parse successful auth response', () => { + const rawResponse = { + User: { + Id: 'user-123', + Name: 'Admin', + ServerId: 'server-456', + Policy: { + IsAdministrator: true, + }, + }, + AccessToken: 'api-key-abc', + ServerId: 'server-456', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.id).toBe('user-123'); + expect(result.username).toBe('Admin'); + expect(result.token).toBe('api-key-abc'); + expect(result.serverId).toBe('server-456'); + expect(result.isAdmin).toBe(true); + }); + + it('should handle non-admin user', () => { + const rawResponse = { + User: { + Id: 'user-789', + Name: 'Regular', + Policy: { + IsAdministrator: false, + }, + }, + AccessToken: 'token-xyz', + ServerId: 'server-123', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.isAdmin).toBe(false); + }); + + it('should handle missing Policy', () => { + const rawResponse = { + User: { + Id: 'guest', + Name: 'Guest', + }, + AccessToken: 'guest-token', + ServerId: 'server', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.isAdmin).toBe(false); + }); + }); +}); + +// ============================================================================ +// PlayMethod and Transcode Detection Tests +// ============================================================================ + +describe('Emby Parser - PlayMethod and Transcode Detection', () => { + it('should use PlayMethod from PlayState for transcode detection', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'Transcode', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(true); + expect(session!.quality.videoDecision).toBe('transcode'); + }); + + it('should detect DirectPlay from PlayMethod', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectPlay', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directplay'); + }); + + it('should detect DirectStream from PlayMethod', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectStream', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directstream'); + }); + + it('should normalize PlayMethod to lowercase', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectPlay', // PascalCase from API + IsPaused: false, + }, + }); + + // Should be normalized to lowercase for consistency with Plex + expect(session!.quality.videoDecision).toBe('directplay'); + }); + + it('should fall back to TranscodingInfo when PlayMethod not available', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + // No PlayMethod + IsPaused: false, + }, + TranscodingInfo: { + Bitrate: 5000000, + }, + }); + + expect(session!.quality.isTranscode).toBe(true); + expect(session!.quality.videoDecision).toBe('transcode'); + }); + + it('should default to directplay when no PlayMethod and no TranscodingInfo', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directplay'); + }); +}); + +// ============================================================================ +// Trailer and Preroll Filtering Tests +// ============================================================================ + +describe('Emby Parser - Trailer and Preroll Filtering', () => { + it('should filter out Trailer sessions', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'trailer-1', + Name: 'Movie Trailer', + Type: 'Trailer', + }, + }); + + expect(session).toBeNull(); + }); + + it('should filter out preroll videos', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'preroll-1', + Name: 'Preroll Video', + Type: 'Video', + ProviderIds: { + 'prerolls.video': 'some-id', + }, + }, + }); + + expect(session).toBeNull(); + }); + + it('should NOT filter regular movies', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'movie-1', + Name: 'Regular Movie', + Type: 'Movie', + ProviderIds: { + Imdb: 'tt1234567', + }, + }, + }); + + expect(session).not.toBeNull(); + expect(session!.media.title).toBe('Regular Movie'); + }); + + it('should NOT filter episodes', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'ep-1', + Name: 'Episode 1', + Type: 'Episode', + SeriesName: 'Test Show', + }, + }); + + expect(session).not.toBeNull(); + }); + + it('should filter trailer sessions from parseSessionsResponse', () => { + const sessions = [ + { + Id: '1', + NowPlayingItem: { Id: 'movie-1', Name: 'Movie', Type: 'Movie' }, + }, + { + Id: '2', + NowPlayingItem: { Id: 'trailer-1', Name: 'Trailer', Type: 'Trailer' }, + }, + { + Id: '3', + NowPlayingItem: { Id: 'ep-1', Name: 'Episode', Type: 'Episode' }, + }, + ]; + + const parsed = parseSessionsResponse(sessions); + + // Should only have movie and episode, trailer filtered out + expect(parsed).toHaveLength(2); + expect(parsed.map((s) => s.sessionKey)).toEqual(['1', '3']); + }); +}); + +// ============================================================================ +// Edge Cases and Type Handling +// ============================================================================ + +describe('Emby Parser Edge Cases', () => { + it('should handle media type conversion', () => { + const makeSession = (type: string) => ({ + NowPlayingItem: { Id: '1', Name: 'Test', Type: type }, + }); + + expect(parseSession(makeSession('Movie'))!.media.type).toBe('movie'); + expect(parseSession(makeSession('Episode'))!.media.type).toBe('episode'); + expect(parseSession(makeSession('Audio'))!.media.type).toBe('track'); + expect(parseSession(makeSession('Photo'))!.media.type).toBe('photo'); + expect(parseSession(makeSession('Unknown'))!.media.type).toBe('unknown'); + }); + + it('should convert ticks to milliseconds correctly', () => { + // 1 hour = 3600000 ms = 36000000000 ticks + const session = parseSession({ + NowPlayingItem: { + Id: '1', + Name: 'Test', + Type: 'Movie', + RunTimeTicks: 36000000000, + }, + PlayState: { + PositionTicks: 18000000000, // 30 minutes + }, + }); + + expect(session!.media.durationMs).toBe(3600000); + expect(session!.playback.positionMs).toBe(1800000); + expect(session!.playback.progressPercent).toBe(50); + }); + + it('should handle zero duration gracefully', () => { + const session = parseSession({ + NowPlayingItem: { + Id: '1', + Name: 'Test', + Type: 'Movie', + RunTimeTicks: 0, + }, + PlayState: { + PositionTicks: 1000000, + }, + }); + + expect(session!.playback.progressPercent).toBe(0); + }); + + it('should handle case insensitivity in media type', () => { + const session = parseSession({ + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'MOVIE' }, + }); + expect(session!.media.type).toBe('movie'); + }); + + it('should build correct image paths', () => { + const session = parseSession({ + Id: 'session-1', + UserId: 'user-123', + UserPrimaryImageTag: 'user-tag', + NowPlayingItem: { + Id: 'item-456', + Name: 'Test', + Type: 'Movie', + ImageTags: { Primary: 'item-tag' }, + }, + }); + + expect(session!.user.thumb).toBe('/Users/user-123/Images/Primary'); + expect(session!.media.thumbPath).toBe('/Items/item-456/Images/Primary'); + }); + + it('should not include image path when tag is missing', () => { + const session = parseSession({ + Id: 'session-1', + UserId: 'user-123', + // No UserPrimaryImageTag + NowPlayingItem: { + Id: 'item-456', + Name: 'Test', + Type: 'Movie', + // No ImageTags + }, + }); + + expect(session!.user.thumb).toBeUndefined(); + expect(session!.media.thumbPath).toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/mediaServer/__tests__/index.test.ts b/apps/server/src/services/mediaServer/__tests__/index.test.ts new file mode 100644 index 0000000..e2ec787 --- /dev/null +++ b/apps/server/src/services/mediaServer/__tests__/index.test.ts @@ -0,0 +1,396 @@ +/** + * Media Server Module Tests + * + * Tests the factory function, client interface compliance, + * and module exports. + */ + +import { describe, it, expect, vi } from 'vitest'; + +// Mock crypto module before imports +vi.mock('../../../utils/crypto.js', () => ({ + decrypt: vi.fn((val: string) => val), // Pass through for tests + encrypt: vi.fn((val: string) => val), + initializeEncryption: vi.fn(), + isEncryptionInitialized: vi.fn(() => true), +})); + +import { + createMediaServerClient, + supportsWatchHistory, + PlexClient, + JellyfinClient, + EmbyClient, + type IMediaServerClient, + type MediaSession, + type MediaUser, + type MediaLibrary, +} from '../index.js'; + +// ============================================================================ +// Factory Function Tests +// ============================================================================ + +describe('createMediaServerClient', () => { + it('should create PlexClient for type "plex"', () => { + const client = createMediaServerClient({ + type: 'plex', + url: 'http://plex.local:32400', + token: 'encrypted-token', + }); + + expect(client).toBeInstanceOf(PlexClient); + expect(client.serverType).toBe('plex'); + }); + + it('should create JellyfinClient for type "jellyfin"', () => { + const client = createMediaServerClient({ + type: 'jellyfin', + url: 'http://jellyfin.local:8096', + token: 'encrypted-token', + }); + + expect(client).toBeInstanceOf(JellyfinClient); + expect(client.serverType).toBe('jellyfin'); + }); + + it('should create EmbyClient for type "emby"', () => { + const client = createMediaServerClient({ + type: 'emby', + url: 'http://emby.local:8096', + token: 'encrypted-token', + }); + + expect(client).toBeInstanceOf(EmbyClient); + expect(client.serverType).toBe('emby'); + }); + + it('should throw error for unknown server type', () => { + expect(() => + createMediaServerClient({ + type: 'unknown' as 'plex', // Force invalid type + url: 'http://unknown.local:8096', + token: 'token', + }) + ).toThrow('Unknown media server type'); + }); + + it('should pass optional config fields', () => { + const client = createMediaServerClient({ + type: 'plex', + url: 'http://plex.local:32400', + token: 'token', + id: 'server-123', + name: 'My Plex Server', + }); + + expect(client).toBeInstanceOf(PlexClient); + }); + + it('should normalize URL by removing trailing slash', () => { + const client = createMediaServerClient({ + type: 'plex', + url: 'http://plex.local:32400/', // With trailing slash + token: 'token', + }); + + // The client should work with normalized URL + expect(client).toBeInstanceOf(PlexClient); + }); +}); + +// ============================================================================ +// Type Guard Tests +// ============================================================================ + +describe('supportsWatchHistory', () => { + it('should return true for PlexClient', () => { + const client = createMediaServerClient({ + type: 'plex', + url: 'http://plex.local:32400', + token: 'token', + }); + + expect(supportsWatchHistory(client)).toBe(true); + }); + + it('should return true for JellyfinClient', () => { + const client = createMediaServerClient({ + type: 'jellyfin', + url: 'http://jellyfin.local:8096', + token: 'token', + }); + + expect(supportsWatchHistory(client)).toBe(true); + }); + + it('should return true for EmbyClient', () => { + const client = createMediaServerClient({ + type: 'emby', + url: 'http://emby.local:8096', + token: 'token', + }); + + expect(supportsWatchHistory(client)).toBe(true); + }); +}); + +// ============================================================================ +// Interface Compliance Tests +// ============================================================================ + +describe('IMediaServerClient Interface Compliance', () => { + const createTestClient = (type: 'plex' | 'jellyfin' | 'emby'): IMediaServerClient => { + const urls = { + plex: 'http://plex.local:32400', + jellyfin: 'http://jellyfin.local:8096', + emby: 'http://emby.local:8096', + }; + return createMediaServerClient({ + type, + url: urls[type], + token: 'test-token', + }); + }; + + describe('PlexClient', () => { + it('should implement serverType property', () => { + const client = createTestClient('plex'); + expect(client.serverType).toBe('plex'); + }); + + it('should implement getSessions method', () => { + const client = createTestClient('plex'); + expect(typeof client.getSessions).toBe('function'); + }); + + it('should implement getUsers method', () => { + const client = createTestClient('plex'); + expect(typeof client.getUsers).toBe('function'); + }); + + it('should implement getLibraries method', () => { + const client = createTestClient('plex'); + expect(typeof client.getLibraries).toBe('function'); + }); + + it('should implement testConnection method', () => { + const client = createTestClient('plex'); + expect(typeof client.testConnection).toBe('function'); + }); + + it('should implement terminateSession method', () => { + const client = createTestClient('plex'); + expect(typeof client.terminateSession).toBe('function'); + }); + }); + + describe('JellyfinClient', () => { + it('should implement serverType property', () => { + const client = createTestClient('jellyfin'); + expect(client.serverType).toBe('jellyfin'); + }); + + it('should implement getSessions method', () => { + const client = createTestClient('jellyfin'); + expect(typeof client.getSessions).toBe('function'); + }); + + it('should implement getUsers method', () => { + const client = createTestClient('jellyfin'); + expect(typeof client.getUsers).toBe('function'); + }); + + it('should implement getLibraries method', () => { + const client = createTestClient('jellyfin'); + expect(typeof client.getLibraries).toBe('function'); + }); + + it('should implement testConnection method', () => { + const client = createTestClient('jellyfin'); + expect(typeof client.testConnection).toBe('function'); + }); + + it('should implement terminateSession method', () => { + const client = createTestClient('jellyfin'); + expect(typeof client.terminateSession).toBe('function'); + }); + }); + + describe('EmbyClient', () => { + it('should implement serverType property', () => { + const client = createTestClient('emby'); + expect(client.serverType).toBe('emby'); + }); + + it('should implement getSessions method', () => { + const client = createTestClient('emby'); + expect(typeof client.getSessions).toBe('function'); + }); + + it('should implement getUsers method', () => { + const client = createTestClient('emby'); + expect(typeof client.getUsers).toBe('function'); + }); + + it('should implement getLibraries method', () => { + const client = createTestClient('emby'); + expect(typeof client.getLibraries).toBe('function'); + }); + + it('should implement testConnection method', () => { + const client = createTestClient('emby'); + expect(typeof client.testConnection).toBe('function'); + }); + + it('should implement terminateSession method', () => { + const client = createTestClient('emby'); + expect(typeof client.terminateSession).toBe('function'); + }); + }); +}); + +// ============================================================================ +// Static Methods Tests +// ============================================================================ + +describe('PlexClient Static Methods', () => { + it('should have initiateOAuth static method', () => { + expect(typeof PlexClient.initiateOAuth).toBe('function'); + }); + + it('should have checkOAuthPin static method', () => { + expect(typeof PlexClient.checkOAuthPin).toBe('function'); + }); + + it('should have verifyServerAdmin static method', () => { + expect(typeof PlexClient.verifyServerAdmin).toBe('function'); + }); + + it('should have getServers static method', () => { + expect(typeof PlexClient.getServers).toBe('function'); + }); + + it('should have getAccountInfo static method', () => { + expect(typeof PlexClient.getAccountInfo).toBe('function'); + }); + + it('should have getFriends static method', () => { + expect(typeof PlexClient.getFriends).toBe('function'); + }); + + it('should have getAllUsersWithLibraries static method', () => { + expect(typeof PlexClient.getAllUsersWithLibraries).toBe('function'); + }); +}); + +describe('JellyfinClient Static Methods', () => { + it('should have authenticate static method', () => { + expect(typeof JellyfinClient.authenticate).toBe('function'); + }); + + it('should have verifyServerAdmin static method', () => { + expect(typeof JellyfinClient.verifyServerAdmin).toBe('function'); + }); +}); + +describe('EmbyClient Static Methods', () => { + it('should have authenticate static method', () => { + expect(typeof EmbyClient.authenticate).toBe('function'); + }); + + it('should have verifyServerAdmin static method', () => { + expect(typeof EmbyClient.verifyServerAdmin).toBe('function'); + }); +}); + +// ============================================================================ +// Type Export Tests +// ============================================================================ + +describe('Module Exports', () => { + it('should export createMediaServerClient factory', () => { + expect(typeof createMediaServerClient).toBe('function'); + }); + + it('should export supportsWatchHistory type guard', () => { + expect(typeof supportsWatchHistory).toBe('function'); + }); + + it('should export PlexClient class', () => { + expect(PlexClient).toBeDefined(); + expect(typeof PlexClient).toBe('function'); + }); + + it('should export JellyfinClient class', () => { + expect(JellyfinClient).toBeDefined(); + expect(typeof JellyfinClient).toBe('function'); + }); + + it('should export EmbyClient class', () => { + expect(EmbyClient).toBeDefined(); + expect(typeof EmbyClient).toBe('function'); + }); + + // Type exports are verified at compile time, but we can check + // that the factory returns properly typed results + it('should return properly typed client from factory', () => { + const client: IMediaServerClient = createMediaServerClient({ + type: 'plex', + url: 'http://plex.local:32400', + token: 'token', + }); + + // TypeScript ensures these methods exist + const _getSessions: () => Promise = client.getSessions.bind(client); + const _getUsers: () => Promise = client.getUsers.bind(client); + const _getLibraries: () => Promise = client.getLibraries.bind(client); + + expect(_getSessions).toBeDefined(); + expect(_getUsers).toBeDefined(); + expect(_getLibraries).toBeDefined(); + }); +}); + +// ============================================================================ +// Polymorphic Usage Tests +// ============================================================================ + +describe('Polymorphic Client Usage', () => { + it('should allow treating all clients as IMediaServerClient', () => { + const clients: IMediaServerClient[] = [ + createMediaServerClient({ type: 'plex', url: 'http://plex:32400', token: 'plex-token' }), + createMediaServerClient({ type: 'jellyfin', url: 'http://jellyfin:8096', token: 'jelly-token' }), + createMediaServerClient({ type: 'emby', url: 'http://emby:8096', token: 'emby-token' }), + ]; + + expect(clients).toHaveLength(3); + expect(clients[0]!.serverType).toBe('plex'); + expect(clients[1]!.serverType).toBe('jellyfin'); + expect(clients[2]!.serverType).toBe('emby'); + + // All should have the same interface methods + for (const client of clients) { + expect(typeof client.getSessions).toBe('function'); + expect(typeof client.getUsers).toBe('function'); + expect(typeof client.getLibraries).toBe('function'); + expect(typeof client.testConnection).toBe('function'); + expect(typeof client.terminateSession).toBe('function'); + } + }); + + it('should support iteration over mixed client types', () => { + const servers = [ + { type: 'plex' as const, url: 'http://plex1:32400', token: 'p1' }, + { type: 'jellyfin' as const, url: 'http://jellyfin1:8096', token: 'j1' }, + { type: 'emby' as const, url: 'http://emby1:8096', token: 'e1' }, + { type: 'plex' as const, url: 'http://plex2:32400', token: 'p2' }, + ]; + + const clients = servers.map((s) => createMediaServerClient(s)); + + expect(clients.filter((c) => c.serverType === 'plex')).toHaveLength(2); + expect(clients.filter((c) => c.serverType === 'jellyfin')).toHaveLength(1); + expect(clients.filter((c) => c.serverType === 'emby')).toHaveLength(1); + }); +}); diff --git a/apps/server/src/services/mediaServer/__tests__/jellyfin-parser.test.ts b/apps/server/src/services/mediaServer/__tests__/jellyfin-parser.test.ts new file mode 100644 index 0000000..9ed2342 --- /dev/null +++ b/apps/server/src/services/mediaServer/__tests__/jellyfin-parser.test.ts @@ -0,0 +1,812 @@ +/** + * Jellyfin Parser Tests + * + * Tests the pure parsing functions that convert raw Jellyfin API responses + * into typed MediaSession, MediaUser, and MediaLibrary objects. + */ + +import { describe, it, expect } from 'vitest'; +import { + parseSession, + parseSessionsResponse, + parseUser, + parseUsersResponse, + parseLibrary, + parseLibrariesResponse, + parseWatchHistoryItem, + parseWatchHistoryResponse, + parseActivityLogItem, + parseActivityLogResponse, + parseAuthResponse, +} from '../jellyfin/parser.js'; + +// ============================================================================ +// Session Parsing Tests +// ============================================================================ + +describe('Jellyfin Session Parser', () => { + describe('parseSession', () => { + it('should parse a movie session', () => { + const rawSession = { + Id: 'session-123', + UserId: 'user-456', + UserName: 'John', + UserPrimaryImageTag: 'avatar-tag', + DeviceName: "John's TV", + DeviceId: 'device-uuid-789', + Client: 'Jellyfin Web', + DeviceType: 'TV', + RemoteEndPoint: '203.0.113.50', + NowPlayingItem: { + Id: 'item-abc', + Name: 'Inception', + Type: 'Movie', + RunTimeTicks: 90000000000, // 150 minutes in ticks (10000 ticks/ms) + ProductionYear: 2010, + ImageTags: { Primary: 'poster-tag' }, + }, + PlayState: { + PositionTicks: 36000000000, // 60 minutes + IsPaused: false, + }, + TranscodingInfo: { + IsVideoDirect: true, + Bitrate: 20000000, + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.sessionKey).toBe('session-123'); + expect(session!.mediaId).toBe('item-abc'); + expect(session!.user.id).toBe('user-456'); + expect(session!.user.username).toBe('John'); + expect(session!.media.title).toBe('Inception'); + expect(session!.media.type).toBe('movie'); + expect(session!.media.durationMs).toBe(9000000); // 150 minutes in ms + expect(session!.media.year).toBe(2010); + expect(session!.playback.state).toBe('playing'); + expect(session!.playback.positionMs).toBe(3600000); // 60 minutes in ms + expect(session!.playback.progressPercent).toBe(40); + expect(session!.player.name).toBe("John's TV"); + expect(session!.player.deviceId).toBe('device-uuid-789'); + expect(session!.network.ipAddress).toBe('203.0.113.50'); + expect(session!.quality.isTranscode).toBe(false); + }); + + it('should parse an episode session with show metadata', () => { + const rawSession = { + Id: 'session-ep', + UserId: 'user-1', + UserName: 'Jane', + DeviceName: 'iPhone', + DeviceId: 'iphone-123', + Client: 'Jellyfin iOS', + RemoteEndPoint: '192.168.1.100', + NowPlayingItem: { + Id: 'episode-id', + Name: 'Pilot', + Type: 'Episode', + RunTimeTicks: 36000000000, // 60 minutes + SeriesName: 'Breaking Bad', + SeriesId: 'series-bb', + ParentIndexNumber: 1, + IndexNumber: 1, + SeasonName: 'Season 1', + SeriesPrimaryImageTag: 'series-poster-tag', + }, + PlayState: { + PositionTicks: 18000000000, // 30 minutes + IsPaused: true, + }, + TranscodingInfo: { + IsVideoDirect: false, + Bitrate: 5000000, + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.media.type).toBe('episode'); + expect(session!.playback.state).toBe('paused'); + expect(session!.playback.progressPercent).toBe(50); + expect(session!.quality.isTranscode).toBe(true); + expect(session!.episode).toBeDefined(); + expect(session!.episode?.showTitle).toBe('Breaking Bad'); + expect(session!.episode?.showId).toBe('series-bb'); + expect(session!.episode?.seasonNumber).toBe(1); + expect(session!.episode?.episodeNumber).toBe(1); + expect(session!.episode?.seasonName).toBe('Season 1'); + }); + + it('should return null for session without NowPlayingItem', () => { + const rawSession = { + Id: 'session-idle', + UserId: 'user-1', + UserName: 'John', + // No NowPlayingItem - user is idle + }; + + const session = parseSession(rawSession); + expect(session).toBeNull(); + }); + + it('should handle missing optional fields gracefully', () => { + const rawSession = { + Id: 'minimal', + NowPlayingItem: { + Id: 'item-1', + Name: 'Test', + Type: 'Movie', + }, + }; + + const session = parseSession(rawSession); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(''); + expect(session!.user.thumb).toBeUndefined(); + expect(session!.media.durationMs).toBe(0); + expect(session!.playback.positionMs).toBe(0); + }); + + it('should get bitrate from MediaSources when no transcoding', () => { + const rawSession = { + Id: 'session-direct', + NowPlayingItem: { + Id: 'item-1', + Name: 'Movie', + Type: 'Movie', + MediaSources: [{ Bitrate: 30000000 }], // 30000000 bps = 30000 kbps + }, + PlayState: {}, + }; + + const session = parseSession(rawSession); + // Parser normalizes Jellyfin bps to kbps for consistency with Plex + expect(session!.quality.bitrate).toBe(30000); + }); + }); + + describe('parseSessionsResponse', () => { + it('should filter sessions to only those with active playback', () => { + const sessions = [ + { + Id: '1', + UserId: 'u1', + NowPlayingItem: { Id: 'i1', Name: 'Playing', Type: 'Movie' }, + }, + { + Id: '2', + UserId: 'u2', + // No NowPlayingItem - idle session + }, + { + Id: '3', + UserId: 'u3', + NowPlayingItem: { Id: 'i3', Name: 'Also Playing', Type: 'Episode' }, + }, + ]; + + const parsed = parseSessionsResponse(sessions); + + expect(parsed).toHaveLength(2); + expect(parsed[0]!.sessionKey).toBe('1'); + expect(parsed[1]!.sessionKey).toBe('3'); + }); + + it('should return empty array for non-array input', () => { + expect(parseSessionsResponse(null as unknown as unknown[])).toEqual([]); + expect(parseSessionsResponse('not an array' as unknown as unknown[])).toEqual([]); + }); + }); +}); + +// ============================================================================ +// User Parsing Tests +// ============================================================================ + +describe('Jellyfin User Parser', () => { + describe('parseUser', () => { + it('should parse user with admin policy', () => { + const rawUser = { + Id: 'admin-123', + Name: 'Administrator', + PrimaryImageTag: 'avatar-tag', + HasPassword: true, + Policy: { + IsAdministrator: true, + IsDisabled: false, + }, + LastLoginDate: '2024-01-15T10:30:00.000Z', + LastActivityDate: '2024-01-15T12:45:00.000Z', + }; + + const user = parseUser(rawUser); + + expect(user.id).toBe('admin-123'); + expect(user.username).toBe('Administrator'); + // thumb is now a full path, not just the image tag + expect(user.thumb).toBe('/Users/admin-123/Images/Primary'); + expect(user.isAdmin).toBe(true); + expect(user.isDisabled).toBe(false); + expect(user.lastLoginAt).toEqual(new Date('2024-01-15T10:30:00.000Z')); + expect(user.lastActivityAt).toEqual(new Date('2024-01-15T12:45:00.000Z')); + }); + + it('should parse regular user', () => { + const rawUser = { + Id: 'user-456', + Name: 'Regular User', + HasPassword: false, + Policy: { + IsAdministrator: false, + IsDisabled: false, + }, + }; + + const user = parseUser(rawUser); + + expect(user.isAdmin).toBe(false); + expect(user.lastLoginAt).toBeUndefined(); + }); + + it('should parse disabled user', () => { + const rawUser = { + Id: 'disabled-user', + Name: 'Disabled', + Policy: { + IsAdministrator: false, + IsDisabled: true, + }, + }; + + const user = parseUser(rawUser); + + expect(user.isDisabled).toBe(true); + }); + + it('should handle missing Policy object', () => { + const rawUser = { + Id: 'no-policy', + Name: 'Guest', + }; + + const user = parseUser(rawUser); + + expect(user.isAdmin).toBe(false); + expect(user.isDisabled).toBe(false); + }); + }); + + describe('parseUsersResponse', () => { + it('should parse array of users', () => { + const users = [ + { Id: '1', Name: 'User1', Policy: { IsAdministrator: true } }, + { Id: '2', Name: 'User2', Policy: { IsAdministrator: false } }, + ]; + + const parsed = parseUsersResponse(users); + + expect(parsed).toHaveLength(2); + expect(parsed[0]!.isAdmin).toBe(true); + expect(parsed[1]!.isAdmin).toBe(false); + }); + + it('should return empty array for non-array input', () => { + expect(parseUsersResponse(null as unknown as unknown[])).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Library Parsing Tests +// ============================================================================ + +describe('Jellyfin Library Parser', () => { + describe('parseLibrary', () => { + it('should parse virtual folder', () => { + const rawFolder = { + ItemId: 'lib-123', + Name: 'Movies', + CollectionType: 'movies', + Locations: ['/media/movies', '/media/movies2'], + }; + + const library = parseLibrary(rawFolder); + + expect(library.id).toBe('lib-123'); + expect(library.name).toBe('Movies'); + expect(library.type).toBe('movies'); + expect(library.locations).toEqual(['/media/movies', '/media/movies2']); + }); + + it('should handle missing CollectionType', () => { + const rawFolder = { + ItemId: 'lib-456', + Name: 'Mixed Content', + }; + + const library = parseLibrary(rawFolder); + + expect(library.type).toBe('unknown'); + expect(library.locations).toEqual([]); + }); + }); + + describe('parseLibrariesResponse', () => { + it('should parse array of folders', () => { + const folders = [ + { ItemId: '1', Name: 'Movies', CollectionType: 'movies' }, + { ItemId: '2', Name: 'TV Shows', CollectionType: 'tvshows' }, + ]; + + const libraries = parseLibrariesResponse(folders); + + expect(libraries).toHaveLength(2); + expect(libraries[0]!.name).toBe('Movies'); + expect(libraries[1]!.name).toBe('TV Shows'); + }); + }); +}); + +// ============================================================================ +// Watch History Parsing Tests +// ============================================================================ + +describe('Jellyfin Watch History Parser', () => { + describe('parseWatchHistoryItem', () => { + it('should parse movie history item', () => { + const rawItem = { + Id: 'movie-123', + Name: 'The Matrix', + Type: 'Movie', + ProductionYear: 1999, + RunTimeTicks: 81600000000, + UserData: { + PlayCount: 3, + LastPlayedDate: '2024-01-10T20:00:00.000Z', + }, + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.mediaId).toBe('movie-123'); + expect(item.title).toBe('The Matrix'); + expect(item.type).toBe('movie'); + expect(item.playCount).toBe(3); + expect(item.watchedAt).toBe('2024-01-10T20:00:00.000Z'); + expect(item.episode).toBeUndefined(); + }); + + it('should parse episode history with show metadata', () => { + const rawItem = { + Id: 'ep-456', + Name: 'Pilot', + Type: 'Episode', + SeriesName: 'Lost', + ParentIndexNumber: 1, + IndexNumber: 1, + UserData: { + PlayCount: 1, + LastPlayedDate: '2024-01-12T21:00:00.000Z', + }, + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.type).toBe('episode'); + expect(item.episode).toBeDefined(); + expect(item.episode?.showTitle).toBe('Lost'); + expect(item.episode?.seasonNumber).toBe(1); + expect(item.episode?.episodeNumber).toBe(1); + }); + }); + + describe('parseWatchHistoryResponse', () => { + it('should parse Items from response', () => { + const response = { + Items: [ + { Id: '1', Name: 'Item 1', Type: 'Movie', UserData: { PlayCount: 1 } }, + { Id: '2', Name: 'Item 2', Type: 'Episode', SeriesName: 'Show' }, + ], + }; + + const items = parseWatchHistoryResponse(response); + + expect(items).toHaveLength(2); + expect(items[1]!.episode?.showTitle).toBe('Show'); + }); + + it('should return empty array for missing Items', () => { + expect(parseWatchHistoryResponse({})).toEqual([]); + expect(parseWatchHistoryResponse(null)).toEqual([]); + }); + }); +}); + +// ============================================================================ +// Activity Log Parsing Tests +// ============================================================================ + +describe('Jellyfin Activity Log Parser', () => { + describe('parseActivityLogItem', () => { + it('should parse activity entry', () => { + const rawEntry = { + Id: 12345, + Name: 'John authenticated successfully', + Overview: 'User John logged in from 192.168.1.100', + ShortOverview: 'Login successful', + Type: 'AuthenticationSucceeded', + UserId: 'user-123', + Date: '2024-01-15T10:30:00.000Z', + Severity: 'Information', + }; + + const entry = parseActivityLogItem(rawEntry); + + expect(entry.id).toBe(12345); + expect(entry.name).toBe('John authenticated successfully'); + expect(entry.type).toBe('AuthenticationSucceeded'); + expect(entry.userId).toBe('user-123'); + expect(entry.severity).toBe('Information'); + }); + + it('should handle playback activity', () => { + const rawEntry = { + Id: 67890, + Name: 'User started playing Movie', + Type: 'VideoPlayback', + ItemId: 'item-abc', + UserId: 'user-456', + Date: '2024-01-15T20:00:00.000Z', + Severity: 'Information', + }; + + const entry = parseActivityLogItem(rawEntry); + + expect(entry.type).toBe('VideoPlayback'); + expect(entry.itemId).toBe('item-abc'); + }); + }); + + describe('parseActivityLogResponse', () => { + it('should parse Items array', () => { + const response = { + Items: [ + { Id: 1, Name: 'Entry 1', Type: 'Login', Date: '2024-01-15' }, + { Id: 2, Name: 'Entry 2', Type: 'Playback', Date: '2024-01-16' }, + ], + }; + + const entries = parseActivityLogResponse(response); + + expect(entries).toHaveLength(2); + }); + }); +}); + +// ============================================================================ +// Authentication Response Parsing Tests +// ============================================================================ + +describe('Jellyfin Auth Response Parser', () => { + describe('parseAuthResponse', () => { + it('should parse successful auth response', () => { + const rawResponse = { + User: { + Id: 'user-123', + Name: 'Admin', + ServerId: 'server-456', + Policy: { + IsAdministrator: true, + }, + }, + AccessToken: 'jwt-token-abc', + ServerId: 'server-456', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.id).toBe('user-123'); + expect(result.username).toBe('Admin'); + expect(result.token).toBe('jwt-token-abc'); + expect(result.serverId).toBe('server-456'); + expect(result.isAdmin).toBe(true); + }); + + it('should handle non-admin user', () => { + const rawResponse = { + User: { + Id: 'user-789', + Name: 'Regular', + Policy: { + IsAdministrator: false, + }, + }, + AccessToken: 'token-xyz', + ServerId: 'server-123', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.isAdmin).toBe(false); + }); + + it('should handle missing Policy', () => { + const rawResponse = { + User: { + Id: 'guest', + Name: 'Guest', + }, + AccessToken: 'guest-token', + ServerId: 'server', + }; + + const result = parseAuthResponse(rawResponse); + + expect(result.isAdmin).toBe(false); + }); + }); +}); + +// ============================================================================ +// New Features: PlayMethod, LastPausedDate, Trailer Filtering +// ============================================================================ + +describe('Jellyfin Parser - PlayMethod and Transcode Detection', () => { + it('should use PlayMethod from PlayState for transcode detection', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'Transcode', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(true); + expect(session!.quality.videoDecision).toBe('transcode'); + }); + + it('should detect DirectPlay from PlayMethod', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectPlay', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directplay'); + }); + + it('should detect DirectStream from PlayMethod', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectStream', + IsPaused: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(false); + expect(session!.quality.videoDecision).toBe('directstream'); + }); + + it('should normalize PlayMethod to lowercase', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + PlayMethod: 'DirectPlay', // PascalCase from API + IsPaused: false, + }, + }); + + // Should be normalized to lowercase for consistency with Plex + expect(session!.quality.videoDecision).toBe('directplay'); + }); + + it('should fall back to TranscodingInfo when PlayMethod not available', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + // No PlayMethod + IsPaused: false, + }, + TranscodingInfo: { + IsVideoDirect: false, + }, + }); + + expect(session!.quality.isTranscode).toBe(true); + expect(session!.quality.videoDecision).toBe('transcode'); + }); +}); + +describe('Jellyfin Parser - LastPausedDate', () => { + it('should parse LastPausedDate when session is paused', () => { + const pauseTime = '2024-01-15T10:30:00.000Z'; + const session = parseSession({ + Id: 'session-1', + LastPausedDate: pauseTime, + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + IsPaused: true, + }, + }); + + expect(session!.lastPausedDate).toEqual(new Date(pauseTime)); + expect(session!.playback.state).toBe('paused'); + }); + + it('should not have lastPausedDate when playing', () => { + const session = parseSession({ + Id: 'session-1', + // No LastPausedDate when playing + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { + IsPaused: false, + }, + }); + + expect(session!.lastPausedDate).toBeUndefined(); + expect(session!.playback.state).toBe('playing'); + }); + + it('should handle null LastPausedDate', () => { + const session = parseSession({ + Id: 'session-1', + LastPausedDate: null, + NowPlayingItem: { Id: '1', Name: 'Test', Type: 'Movie' }, + PlayState: { IsPaused: false }, + }); + + expect(session!.lastPausedDate).toBeUndefined(); + }); +}); + +describe('Jellyfin Parser - Trailer and Preroll Filtering', () => { + it('should filter out Trailer sessions', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'trailer-1', + Name: 'Movie Trailer', + Type: 'Trailer', + }, + }); + + expect(session).toBeNull(); + }); + + it('should filter out preroll videos', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'preroll-1', + Name: 'Preroll Video', + Type: 'Video', + ProviderIds: { + 'prerolls.video': 'some-id', + }, + }, + }); + + expect(session).toBeNull(); + }); + + it('should NOT filter regular movies', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'movie-1', + Name: 'Regular Movie', + Type: 'Movie', + ProviderIds: { + Imdb: 'tt1234567', + }, + }, + }); + + expect(session).not.toBeNull(); + expect(session!.media.title).toBe('Regular Movie'); + }); + + it('should NOT filter episodes', () => { + const session = parseSession({ + Id: 'session-1', + NowPlayingItem: { + Id: 'ep-1', + Name: 'Episode 1', + Type: 'Episode', + SeriesName: 'Test Show', + }, + }); + + expect(session).not.toBeNull(); + }); + + it('should filter trailer sessions from parseSessionsResponse', () => { + const sessions = [ + { + Id: '1', + NowPlayingItem: { Id: 'movie-1', Name: 'Movie', Type: 'Movie' }, + }, + { + Id: '2', + NowPlayingItem: { Id: 'trailer-1', Name: 'Trailer', Type: 'Trailer' }, + }, + { + Id: '3', + NowPlayingItem: { Id: 'ep-1', Name: 'Episode', Type: 'Episode' }, + }, + ]; + + const parsed = parseSessionsResponse(sessions); + + // Should only have movie and episode, trailer filtered out + expect(parsed).toHaveLength(2); + expect(parsed.map(s => s.sessionKey)).toEqual(['1', '3']); + }); +}); + +// ============================================================================ +// Edge Cases and Type Handling +// ============================================================================ + +describe('Jellyfin Parser Edge Cases', () => { + it('should handle media type conversion', () => { + const makeSession = (type: string) => ({ + NowPlayingItem: { Id: '1', Name: 'Test', Type: type }, + }); + + expect(parseSession(makeSession('Movie'))!.media.type).toBe('movie'); + expect(parseSession(makeSession('Episode'))!.media.type).toBe('episode'); + expect(parseSession(makeSession('Audio'))!.media.type).toBe('track'); + expect(parseSession(makeSession('Photo'))!.media.type).toBe('photo'); + expect(parseSession(makeSession('Unknown'))!.media.type).toBe('unknown'); + }); + + it('should convert ticks to milliseconds correctly', () => { + // 1 hour = 3600000 ms = 36000000000 ticks + const session = parseSession({ + NowPlayingItem: { + Id: '1', + Name: 'Test', + Type: 'Movie', + RunTimeTicks: 36000000000, + }, + PlayState: { + PositionTicks: 18000000000, // 30 minutes + }, + }); + + expect(session!.media.durationMs).toBe(3600000); + expect(session!.playback.positionMs).toBe(1800000); + expect(session!.playback.progressPercent).toBe(50); + }); + + it('should handle zero duration gracefully', () => { + const session = parseSession({ + NowPlayingItem: { + Id: '1', + Name: 'Test', + Type: 'Movie', + RunTimeTicks: 0, + }, + PlayState: { + PositionTicks: 1000000, + }, + }); + + expect(session!.playback.progressPercent).toBe(0); + }); +}); diff --git a/apps/server/src/services/mediaServer/__tests__/plex-parser.test.ts b/apps/server/src/services/mediaServer/__tests__/plex-parser.test.ts new file mode 100644 index 0000000..2b9c17e --- /dev/null +++ b/apps/server/src/services/mediaServer/__tests__/plex-parser.test.ts @@ -0,0 +1,588 @@ +/** + * Plex Parser Tests + * + * Tests the pure parsing functions that convert raw Plex API responses + * into typed MediaSession, MediaUser, and MediaLibrary objects. + */ + +import { describe, it, expect } from 'vitest'; +import { + parseSession, + parseSessionsResponse, + parseLocalUser, + parseUsersResponse, + parseLibrary, + parseLibrariesResponse, + parseWatchHistoryItem, + parseWatchHistoryResponse, + parseServerConnection, + parseServerResourcesResponse, + extractXmlAttribute, + extractXmlId, + parseXmlUsersResponse, + parseSharedServersXml, + parsePlexTvUser, +} from '../plex/parser.js'; + +// ============================================================================ +// Session Parsing Tests +// ============================================================================ + +describe('Plex Session Parser', () => { + describe('parseSession', () => { + it('should parse a movie session', () => { + const rawSession = { + sessionKey: '12345', + ratingKey: '67890', + title: 'Inception', + type: 'movie', + duration: 9000000, // 150 minutes in ms + viewOffset: 3600000, // 60 minutes in ms + year: 2010, + thumb: '/library/metadata/67890/thumb/1234', + User: { id: '1', title: 'John', thumb: '/avatars/1.jpg' }, + Player: { + title: "John's iPhone", + machineIdentifier: 'device-uuid-123', + product: 'Plex for iOS', + device: 'iPhone', + platform: 'iOS', + address: '192.168.1.100', + remotePublicAddress: '203.0.113.50', + state: 'playing', + local: true, + }, + Media: [{ bitrate: 8000 }], + TranscodeSession: { videoDecision: 'directplay' }, + }; + + const session = parseSession(rawSession); + + expect(session.sessionKey).toBe('12345'); + expect(session.mediaId).toBe('67890'); + expect(session.user.id).toBe('1'); + expect(session.user.username).toBe('John'); + expect(session.media.title).toBe('Inception'); + expect(session.media.type).toBe('movie'); + expect(session.media.durationMs).toBe(9000000); + expect(session.media.year).toBe(2010); + expect(session.playback.state).toBe('playing'); + expect(session.playback.positionMs).toBe(3600000); + expect(session.playback.progressPercent).toBe(40); + expect(session.player.name).toBe("John's iPhone"); + expect(session.player.deviceId).toBe('device-uuid-123'); + expect(session.network.ipAddress).toBe('203.0.113.50'); // Prefers public IP + expect(session.network.isLocal).toBe(true); + expect(session.quality.bitrate).toBe(8000); + expect(session.quality.isTranscode).toBe(false); + expect(session.episode).toBeUndefined(); + }); + + it('should parse an episode session with show metadata', () => { + const rawSession = { + sessionKey: '11111', + ratingKey: '22222', + title: 'Pilot', + type: 'episode', + duration: 3600000, + viewOffset: 1800000, + grandparentTitle: 'Breaking Bad', + parentTitle: 'Season 1', + grandparentRatingKey: '33333', + parentIndex: 1, + index: 1, + thumb: '/library/metadata/22222/thumb/456', + grandparentThumb: '/library/metadata/33333/thumb/789', + User: { id: '2', title: 'Jane' }, + Player: { + title: 'Living Room TV', + machineIdentifier: 'tv-uuid', + state: 'paused', + local: false, + address: '192.168.1.50', + remotePublicAddress: '198.51.100.25', + }, + Media: [{ bitrate: 20000 }], + TranscodeSession: { videoDecision: 'transcode' }, + }; + + const session = parseSession(rawSession); + + expect(session.media.type).toBe('episode'); + expect(session.playback.state).toBe('paused'); + expect(session.playback.progressPercent).toBe(50); + expect(session.quality.isTranscode).toBe(true); + expect(session.episode).toBeDefined(); + expect(session.episode?.showTitle).toBe('Breaking Bad'); + expect(session.episode?.seasonNumber).toBe(1); + expect(session.episode?.episodeNumber).toBe(1); + expect(session.episode?.seasonName).toBe('Season 1'); + expect(session.episode?.showId).toBe('33333'); + }); + + it('should handle missing optional fields gracefully', () => { + const rawSession = { + sessionKey: 'minimal', + type: 'movie', + User: {}, + Player: {}, + }; + + const session = parseSession(rawSession); + + expect(session.sessionKey).toBe('minimal'); + expect(session.mediaId).toBe(''); + expect(session.user.id).toBe(''); + expect(session.user.username).toBe(''); + expect(session.user.thumb).toBeUndefined(); + expect(session.media.durationMs).toBe(0); + expect(session.playback.progressPercent).toBe(0); + expect(session.quality.bitrate).toBe(0); + }); + + it('should fall back to local IP when no public IP available', () => { + const rawSession = { + sessionKey: 'local-only', + Player: { + address: '192.168.1.100', + remotePublicAddress: '', + }, + }; + + const session = parseSession(rawSession); + expect(session.network.ipAddress).toBe('192.168.1.100'); + }); + }); + + describe('parseSessionsResponse', () => { + it('should parse full MediaContainer response', () => { + const response = { + MediaContainer: { + Metadata: [ + { + sessionKey: '1', + title: 'Movie 1', + type: 'movie', + User: { id: '1', title: 'User1' }, + Player: { title: 'Device1', machineIdentifier: 'dev1' }, + }, + { + sessionKey: '2', + title: 'Movie 2', + type: 'movie', + User: { id: '2', title: 'User2' }, + Player: { title: 'Device2', machineIdentifier: 'dev2' }, + }, + ], + }, + }; + + const sessions = parseSessionsResponse(response); + + expect(sessions).toHaveLength(2); + expect(sessions[0]!.sessionKey).toBe('1'); + expect(sessions[1]!.sessionKey).toBe('2'); + }); + + it('should return empty array for missing MediaContainer', () => { + expect(parseSessionsResponse({})).toEqual([]); + expect(parseSessionsResponse({ MediaContainer: {} })).toEqual([]); + expect(parseSessionsResponse(null)).toEqual([]); + }); + }); +}); + +// ============================================================================ +// User Parsing Tests +// ============================================================================ + +describe('Plex User Parser', () => { + describe('parseLocalUser', () => { + it('should parse local Plex account', () => { + const rawUser = { + id: '1', + name: 'admin', + thumb: '/avatars/admin.jpg', + }; + + const user = parseLocalUser(rawUser); + + expect(user.id).toBe('1'); + expect(user.username).toBe('admin'); + expect(user.thumb).toBe('/avatars/admin.jpg'); + expect(user.isAdmin).toBe(true); // id=1 is admin + expect(user.email).toBeUndefined(); + }); + + it('should mark non-admin users correctly', () => { + const rawUser = { id: '5', name: 'guest' }; + const user = parseLocalUser(rawUser); + + expect(user.isAdmin).toBe(false); + }); + }); + + describe('parseUsersResponse', () => { + it('should parse MediaContainer Account response', () => { + const response = { + MediaContainer: { + Account: [ + { id: '1', name: 'admin' }, + { id: '2', name: 'guest' }, + ], + }, + }; + + const users = parseUsersResponse(response); + + expect(users).toHaveLength(2); + expect(users[0]!.isAdmin).toBe(true); + expect(users[1]!.isAdmin).toBe(false); + }); + }); + + describe('parsePlexTvUser', () => { + it('should parse plex.tv user with shared libraries', () => { + const rawUser = { + id: '12345', + username: 'plex_user', + email: 'user@example.com', + thumb: 'https://plex.tv/avatars/user.jpg', + home: true, + }; + + const user = parsePlexTvUser(rawUser, ['1', '2', '3']); + + expect(user.id).toBe('12345'); + expect(user.username).toBe('plex_user'); + expect(user.email).toBe('user@example.com'); + expect(user.isHomeUser).toBe(true); + expect(user.sharedLibraries).toEqual(['1', '2', '3']); + }); + }); +}); + +// ============================================================================ +// Library Parsing Tests +// ============================================================================ + +describe('Plex Library Parser', () => { + describe('parseLibrary', () => { + it('should parse library directory', () => { + const rawLib = { + key: '1', + title: 'Movies', + type: 'movie', + agent: 'tv.plex.agents.movie', + scanner: 'Plex Movie', + uuid: 'abc-123', + }; + + const library = parseLibrary(rawLib); + + expect(library.id).toBe('1'); + expect(library.name).toBe('Movies'); + expect(library.type).toBe('movie'); + expect(library.agent).toBe('tv.plex.agents.movie'); + expect(library.scanner).toBe('Plex Movie'); + }); + }); + + describe('parseLibrariesResponse', () => { + it('should parse MediaContainer Directory response', () => { + const response = { + MediaContainer: { + Directory: [ + { key: '1', title: 'Movies', type: 'movie' }, + { key: '2', title: 'TV Shows', type: 'show' }, + ], + }, + }; + + const libraries = parseLibrariesResponse(response); + + expect(libraries).toHaveLength(2); + expect(libraries[0]!.name).toBe('Movies'); + expect(libraries[1]!.name).toBe('TV Shows'); + }); + }); +}); + +// ============================================================================ +// Watch History Parsing Tests +// ============================================================================ + +describe('Plex Watch History Parser', () => { + describe('parseWatchHistoryItem', () => { + it('should parse movie history item', () => { + const rawItem = { + ratingKey: '12345', + title: 'The Matrix', + type: 'movie', + lastViewedAt: 1700000000, + accountID: '1', + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.mediaId).toBe('12345'); + expect(item.title).toBe('The Matrix'); + expect(item.type).toBe('movie'); + expect(item.watchedAt).toBe(1700000000); + expect(item.episode).toBeUndefined(); + }); + + it('should parse episode history with show metadata', () => { + const rawItem = { + ratingKey: '67890', + title: 'Pilot', + type: 'episode', + grandparentTitle: 'Lost', + parentIndex: 1, + index: 1, + viewedAt: 1699999999, + }; + + const item = parseWatchHistoryItem(rawItem); + + expect(item.type).toBe('episode'); + expect(item.episode).toBeDefined(); + expect(item.episode?.showTitle).toBe('Lost'); + expect(item.episode?.seasonNumber).toBe(1); + expect(item.episode?.episodeNumber).toBe(1); + }); + }); + + describe('parseWatchHistoryResponse', () => { + it('should parse MediaContainer Metadata response', () => { + const response = { + MediaContainer: { + Metadata: [ + { ratingKey: '1', title: 'Item 1', type: 'movie' }, + { ratingKey: '2', title: 'Item 2', type: 'episode', grandparentTitle: 'Show' }, + ], + }, + }; + + const items = parseWatchHistoryResponse(response); + + expect(items).toHaveLength(2); + expect(items[1]!.episode?.showTitle).toBe('Show'); + }); + }); +}); + +// ============================================================================ +// Server Resource Parsing Tests +// ============================================================================ + +describe('Plex Server Resource Parser', () => { + describe('parseServerConnection', () => { + it('should parse connection with all fields', () => { + const rawConn = { + protocol: 'https', + address: 'plex.example.com', + port: 32400, + uri: 'https://plex.example.com:32400', + local: false, + }; + + const conn = parseServerConnection(rawConn); + + expect(conn.protocol).toBe('https'); + expect(conn.address).toBe('plex.example.com'); + expect(conn.port).toBe(32400); + expect(conn.uri).toBe('https://plex.example.com:32400'); + expect(conn.local).toBe(false); + }); + + it('should use defaults for missing fields', () => { + const conn = parseServerConnection({}); + + expect(conn.protocol).toBe('http'); + expect(conn.port).toBe(32400); + expect(conn.local).toBe(false); + }); + }); + + describe('parseServerResourcesResponse', () => { + it('should filter for owned Plex Media Servers only', () => { + const resources = [ + { + name: 'My Server', + product: 'Plex Media Server', + provides: 'server', + owned: true, + connections: [{ uri: 'http://localhost:32400' }], + }, + { + name: 'Shared Server', + product: 'Plex Media Server', + provides: 'server', + owned: false, // Not owned - should be filtered + connections: [], + }, + { + name: 'Player', + product: 'Plex Web', + provides: 'player', // Not a server - should be filtered + owned: true, + connections: [], + }, + ]; + + const servers = parseServerResourcesResponse(resources, 'fallback-token'); + + expect(servers).toHaveLength(1); + expect(servers[0]!.name).toBe('My Server'); + }); + }); +}); + +// ============================================================================ +// XML Parsing Tests +// ============================================================================ + +describe('Plex XML Parser', () => { + describe('extractXmlAttribute', () => { + it('should extract attribute value', () => { + const xml = ''; + + expect(extractXmlAttribute(xml, 'id')).toBe('123'); + expect(extractXmlAttribute(xml, 'username')).toBe('john'); + expect(extractXmlAttribute(xml, 'email')).toBe('john@example.com'); + }); + + it('should return empty string for missing attribute', () => { + const xml = ''; + expect(extractXmlAttribute(xml, 'email')).toBe(''); + }); + }); + + describe('extractXmlId', () => { + it('should extract id attribute with various patterns', () => { + expect(extractXmlId('')).toBe('123'); + expect(extractXmlId(' id="456"')).toBe('456'); + expect(extractXmlId('')).toBe('789'); + }); + }); + + describe('parseXmlUsersResponse', () => { + it('should parse multiple users from XML', () => { + const xml = ` + + + + + `; + + const users = parseXmlUsersResponse(xml); + + expect(users).toHaveLength(2); + expect(users[0]!.id).toBe('1'); + expect(users[0]!.username).toBe('user1'); + expect(users[1]!.isHomeUser).toBe(true); + }); + + it('should handle self-closing User tags', () => { + const xml = ''; + const users = parseXmlUsersResponse(xml); + + expect(users).toHaveLength(1); + expect(users[0]!.id).toBe('1'); + }); + }); + + describe('parseSharedServersXml', () => { + it('should parse shared server info with libraries', () => { + const xml = ` + + +
+
+
+ + +
+ + + `; + + const userMap = parseSharedServersXml(xml); + + expect(userMap.size).toBe(2); + + const user100 = userMap.get('100'); + expect(user100?.serverToken).toBe('token-100'); + expect(user100?.sharedLibraries).toEqual(['1', '2']); + + const user200 = userMap.get('200'); + expect(user200?.sharedLibraries).toEqual(['1']); + }); + + it('should return empty map for no shared servers', () => { + const xml = ''; + const userMap = parseSharedServersXml(xml); + expect(userMap.size).toBe(0); + }); + }); +}); + +// ============================================================================ +// Edge Cases and Error Handling +// ============================================================================ + +describe('Plex Parser Edge Cases', () => { + it('should handle null/undefined inputs gracefully', () => { + expect(parseSessionsResponse(null)).toEqual([]); + expect(parseSessionsResponse(undefined)).toEqual([]); + expect(parseUsersResponse(null)).toEqual([]); + expect(parseLibrariesResponse(null)).toEqual([]); + expect(parseWatchHistoryResponse(null)).toEqual([]); + }); + + it('should handle media type conversion', () => { + expect(parseSession({ type: 'MOVIE', Player: {}, User: {} }).media.type).toBe('movie'); + expect(parseSession({ type: 'Episode', Player: {}, User: {} }).media.type).toBe('episode'); + expect(parseSession({ type: 'Track', Player: {}, User: {} }).media.type).toBe('track'); + expect(parseSession({ type: 'Photo', Player: {}, User: {} }).media.type).toBe('photo'); + expect(parseSession({ type: 'unknown_type', Player: {}, User: {} }).media.type).toBe('unknown'); + }); + + it('should handle buffering state', () => { + const session = parseSession({ + Player: { state: 'buffering' }, + User: {}, + }); + expect(session.playback.state).toBe('buffering'); + }); + + it('should calculate progress percentage correctly', () => { + // 50% progress + const session1 = parseSession({ + duration: 10000, + viewOffset: 5000, + Player: {}, + User: {}, + }); + expect(session1.playback.progressPercent).toBe(50); + + // 0% progress (no duration) + const session2 = parseSession({ + duration: 0, + viewOffset: 5000, + Player: {}, + User: {}, + }); + expect(session2.playback.progressPercent).toBe(0); + + // Cap at 100% + const session3 = parseSession({ + duration: 10000, + viewOffset: 15000, + Player: {}, + User: {}, + }); + expect(session3.playback.progressPercent).toBe(100); + }); +}); diff --git a/apps/server/src/services/mediaServer/emby/client.ts b/apps/server/src/services/mediaServer/emby/client.ts new file mode 100644 index 0000000..feec55f --- /dev/null +++ b/apps/server/src/services/mediaServer/emby/client.ts @@ -0,0 +1,346 @@ +/** + * Emby Media Server Client + * + * Implements IMediaServerClient for Emby servers. + * Provides a unified interface for session tracking, user management, and library access. + * + * Based on Emby OpenAPI specification v4.1.1.0 + */ + +import { fetchJson, embyHeaders } from '../../../utils/http.js'; +import type { + IMediaServerClient, + IMediaServerClientWithHistory, + MediaSession, + MediaUser, + MediaLibrary, + MediaWatchHistoryItem, + MediaServerConfig, +} from '../types.js'; +import { + parseSessionsResponse, + parseUsersResponse, + parseLibrariesResponse, + parseWatchHistoryResponse, + parseActivityLogResponse, + parseAuthResponse, + parseUser, + type EmbyActivityEntry, + type EmbyAuthResult, +} from './parser.js'; + +const CLIENT_NAME = 'Tracearr'; +const CLIENT_VERSION = '1.0.0'; +const DEVICE_ID = 'tracearr-server'; +const DEVICE_NAME = 'Tracearr Server'; + +/** + * Emby Media Server client implementation + * + * @example + * const client = new EmbyClient({ url: 'http://emby.local:8096', token: 'xxx' }); + * const sessions = await client.getSessions(); + */ +export class EmbyClient implements IMediaServerClient, IMediaServerClientWithHistory { + public readonly serverType = 'emby' as const; + + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(config: MediaServerConfig) { + this.baseUrl = config.url.replace(/\/$/, ''); + this.apiKey = config.token; + } + + /** + * Build X-Emby-Authorization header value + * Format: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="..." + */ + private buildAuthHeader(): string { + return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}", Token="${this.apiKey}"`; + } + + /** + * Build headers for Emby API requests + */ + private buildHeaders(): Record { + return { + 'X-Emby-Authorization': this.buildAuthHeader(), + ...embyHeaders(), + }; + } + + // ========================================================================== + // IMediaServerClient Implementation + // ========================================================================== + + /** + * Get all active playback sessions + */ + async getSessions(): Promise { + const data = await fetchJson(`${this.baseUrl}/Sessions`, { + headers: this.buildHeaders(), + service: 'emby', + timeout: 10000, // 10s timeout to prevent polling hangs + }); + + return parseSessionsResponse(data); + } + + /** + * Get all users on this server + */ + async getUsers(): Promise { + const data = await fetchJson(`${this.baseUrl}/Users`, { + headers: this.buildHeaders(), + service: 'emby', + }); + + return parseUsersResponse(data); + } + + /** + * Get all libraries on this server + */ + async getLibraries(): Promise { + const data = await fetchJson(`${this.baseUrl}/Library/VirtualFolders`, { + headers: this.buildHeaders(), + service: 'emby', + }); + + return parseLibrariesResponse(data); + } + + /** + * Test connection to the server + */ + async testConnection(): Promise { + try { + await fetchJson(`${this.baseUrl}/System/Info`, { + headers: this.buildHeaders(), + service: 'emby', + timeout: 10000, + }); + return true; + } catch { + return false; + } + } + + // ========================================================================== + // IMediaServerClientWithHistory Implementation + // ========================================================================== + + /** + * Get watch history for a specific user + * + * Note: Unlike Tautulli, this only returns WHAT was watched, not session details. + * For full session history, users would need dedicated Emby plugins. + */ + async getWatchHistory(options?: { + userId?: string; + limit?: number; + }): Promise { + if (!options?.userId) { + throw new Error('Emby requires a userId for watch history'); + } + + const params = new URLSearchParams({ + Recursive: 'true', + IncludeItemTypes: 'Movie,Episode', + Filters: 'IsPlayed', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: String(options.limit ?? 500), + Fields: 'MediaSources', + }); + + const data = await fetchJson( + `${this.baseUrl}/Users/${options.userId}/Items?${params}`, + { + headers: this.buildHeaders(), + service: 'emby', + } + ); + + return parseWatchHistoryResponse(data); + } + + // ========================================================================== + // Session Control + // ========================================================================== + + /** + * Terminate a playback session by sending a Stop command + * + * @param sessionId - The session ID (same as sessionKey for Emby) + * @param _reason - Ignored (Emby doesn't support user-facing messages) + * @returns true if successful, throws on error + * + * @example + * await client.terminateSession('session-uuid-123'); + */ + async terminateSession(sessionId: string, _reason?: string): Promise { + const response = await fetch(`${this.baseUrl}/Sessions/${sessionId}/Playing/Stop`, { + method: 'POST', + headers: this.buildHeaders(), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized to terminate session'); + } + if (response.status === 404) { + throw new Error('Session not found (may have already ended)'); + } + throw new Error(`Failed to terminate session: ${response.status} ${response.statusText}`); + } + + return true; + } + + // ========================================================================== + // Emby-Specific Methods + // ========================================================================== + + /** + * Get watch history for all users on the server + */ + async getAllUsersWatchHistory( + limit = 200 + ): Promise> { + const allUsers = await this.getUsers(); + const historyMap = new Map(); + + for (const user of allUsers) { + if (user.isDisabled) continue; + try { + const history = await this.getWatchHistory({ userId: user.id, limit }); + historyMap.set(user.id, history); + } catch (error) { + console.error(`Failed to get history for user ${user.username}:`, error); + } + } + + return historyMap; + } + + /** + * Get activity log entries (requires admin) + * + * Activity types to watch for: + * - AuthenticationSucceeded - Successful login + * - AuthenticationFailed - Failed login attempt + * - SessionStarted - New session + * - SessionEnded - Session ended + */ + async getActivityLog(options?: { + minDate?: Date; + limit?: number; + hasUserId?: boolean; + }): Promise { + const params = new URLSearchParams(); + if (options?.limit) params.append('Limit', String(options.limit)); + if (options?.minDate) params.append('MinDate', options.minDate.toISOString()); + if (options?.hasUserId !== undefined) params.append('HasUserId', String(options.hasUserId)); + + const data = await fetchJson( + `${this.baseUrl}/System/ActivityLog/Entries?${params}`, + { + headers: this.buildHeaders(), + service: 'emby', + } + ); + + return parseActivityLogResponse(data); + } + + // ========================================================================== + // Static Methods - Authentication + // ========================================================================== + + /** + * Authenticate with username/password + * Note: Emby uses 'Password' field (not 'Pw' like Jellyfin) + */ + static async authenticate( + serverUrl: string, + username: string, + password: string + ): Promise { + const url = serverUrl.replace(/\/$/, ''); + const authHeader = `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}"`; + + try { + const data = await fetchJson>( + `${url}/Users/AuthenticateByName`, + { + method: 'POST', + headers: { + 'X-Emby-Authorization': authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + Username: username, + Password: password, // Emby uses 'Password', not 'Pw' + }), + service: 'emby', + } + ); + + return parseAuthResponse(data); + } catch (error) { + // Return null for auth failures, rethrow other errors + if (error instanceof Error && error.message.includes('401')) { + return null; + } + throw error; + } + } + + /** + * Verify if API key has admin access to an Emby server + * + * Handles two token types: + * 1. User tokens (from AuthenticateByName) - verified via /Users/Me + * 2. API keys (created in Emby admin) - verified via /Auth/Keys (requires admin) + */ + static async verifyServerAdmin(apiKey: string, serverUrl: string): Promise { + const url = serverUrl.replace(/\/$/, ''); + const authHeader = `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}", Token="${apiKey}"`; + + const headers = { + 'X-Emby-Authorization': authHeader, + Accept: 'application/json', + }; + + // Try /Users/Me first (works for user tokens from authentication) + try { + const data = await fetchJson>(`${url}/Users/Me`, { + headers, + service: 'emby', + timeout: 10000, + }); + + const user = parseUser(data); + return user.isAdmin; + } catch { + // /Users/Me returns 400 for API keys (not user tokens) + // Fall through to try /Auth/Keys + } + + // Try /Auth/Keys (only accessible with admin-level API keys) + try { + await fetchJson(`${url}/Auth/Keys`, { + headers, + service: 'emby', + timeout: 10000, + }); + // If we can access /Auth/Keys, the token has admin access + return true; + } catch { + return false; + } + } +} diff --git a/apps/server/src/services/mediaServer/emby/parser.ts b/apps/server/src/services/mediaServer/emby/parser.ts new file mode 100644 index 0000000..5dd89be --- /dev/null +++ b/apps/server/src/services/mediaServer/emby/parser.ts @@ -0,0 +1,426 @@ +/** + * Emby API Response Parser + * + * Pure functions for parsing raw Emby API responses into typed objects. + * Separated from the client for testability and reuse. + * + * Based on Emby OpenAPI specification v4.1.1.0 + */ + +import { + parseString, + parseNumber, + parseBoolean, + parseOptionalString, + parseOptionalNumber, + getNestedObject, + parseDateString, +} from '../../../utils/parsing.js'; +import type { MediaSession, MediaUser, MediaLibrary, MediaWatchHistoryItem } from '../types.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Emby ticks per millisecond (10,000 ticks = 1ms) */ +const TICKS_PER_MS = 10000; + +// ============================================================================ +// Session Parsing +// ============================================================================ + +/** + * Convert Emby ticks to milliseconds + */ +function ticksToMs(ticks: unknown): number { + const tickNum = parseNumber(ticks); + return Math.floor(tickNum / TICKS_PER_MS); +} + +/** + * Parse Emby media type to unified type + */ +function parseMediaType(type: unknown): MediaSession['media']['type'] { + const typeStr = parseString(type).toLowerCase(); + switch (typeStr) { + case 'movie': + return 'movie'; + case 'episode': + return 'episode'; + case 'audio': + return 'track'; + case 'photo': + return 'photo'; + default: + return 'unknown'; + } +} + +/** + * Parse playback state from Emby to unified state + */ +function parsePlaybackState(isPaused: unknown): MediaSession['playback']['state'] { + return parseBoolean(isPaused) ? 'paused' : 'playing'; +} + +/** + * Calculate progress percentage from position and duration + */ +function calculateProgress(positionMs: number, durationMs: number): number { + if (durationMs <= 0) return 0; + return Math.min(100, Math.round((positionMs / durationMs) * 100)); +} + +/** + * Get bitrate from Emby session in kbps (prefer transcoding bitrate, fall back to source) + * Note: Emby API returns bitrate in bps, so we convert to kbps for consistency with Plex + */ +function getBitrate(session: Record): number { + // Check transcoding info first + const transcodingInfo = getNestedObject(session, 'TranscodingInfo'); + if (transcodingInfo) { + const transcodeBitrate = parseNumber(transcodingInfo.Bitrate); + if (transcodeBitrate > 0) return Math.round(transcodeBitrate / 1000); // bps -> kbps + } + + // Fall back to source media bitrate + const nowPlaying = getNestedObject(session, 'NowPlayingItem'); + const mediaSources = nowPlaying?.MediaSources; + if (Array.isArray(mediaSources) && mediaSources.length > 0) { + const firstSource = mediaSources[0] as Record; + const bitrate = parseNumber(firstSource?.Bitrate); + return Math.round(bitrate / 1000); // bps -> kbps + } + + return 0; +} + +/** + * Get play method from PlayState and normalize to lowercase + * PlayMethod enum: DirectPlay, DirectStream, Transcode + */ +function getPlayMethod(session: Record): string { + const playState = getNestedObject(session, 'PlayState'); + const playMethod = parseOptionalString(playState?.PlayMethod); + + if (playMethod) { + // Normalize to lowercase: DirectPlay -> directplay, DirectStream -> directstream, Transcode -> transcode + return playMethod.toLowerCase(); + } + + // Fall back to checking TranscodingInfo if PlayMethod not available + const transcodingInfo = getNestedObject(session, 'TranscodingInfo'); + if (!transcodingInfo) return 'directplay'; + + // If TranscodingInfo exists, it's transcoding + return 'transcode'; +} + +/** + * Check if session is a trailer or preroll that should be filtered out + */ +function isTrailerOrPreroll(nowPlaying: Record): boolean { + // Filter trailers + const itemType = parseOptionalString(nowPlaying.Type); + if (itemType?.toLowerCase() === 'trailer') return true; + + // Filter preroll videos (check ProviderIds for prerolls.video) + const providerIds = getNestedObject(nowPlaying, 'ProviderIds'); + if (providerIds && 'prerolls.video' in providerIds) return true; + + return false; +} + +/** + * Build Emby image URL path for an item + * Emby images use: /Items/{id}/Images/{type} + */ +function buildItemImagePath(itemId: string, imageTag: string | undefined): string | undefined { + if (!imageTag || !itemId) return undefined; + return `/Items/${itemId}/Images/Primary`; +} + +/** + * Build Emby image URL path for a user avatar + * Emby user images use: /Users/{id}/Images/Primary + */ +function buildUserImagePath(userId: string, imageTag: string | undefined): string | undefined { + if (!imageTag || !userId) return undefined; + return `/Users/${userId}/Images/Primary`; +} + +/** + * Parse raw Emby session data into a MediaSession object + */ +export function parseSession(session: Record): MediaSession | null { + const nowPlaying = getNestedObject(session, 'NowPlayingItem'); + if (!nowPlaying) return null; // No active playback + + // Filter out trailers and prerolls + if (isTrailerOrPreroll(nowPlaying)) return null; + + const playState = getNestedObject(session, 'PlayState'); + const imageTags = getNestedObject(nowPlaying, 'ImageTags'); + + const durationMs = ticksToMs(nowPlaying.RunTimeTicks); + const positionMs = ticksToMs(playState?.PositionTicks); + const mediaType = parseMediaType(nowPlaying.Type); + + // Use PlayMethod for accurate transcode detection + const videoDecision = getPlayMethod(session); + const isTranscode = videoDecision === 'transcode'; + + // Build full image paths for Emby (not just image tag IDs) + const itemId = parseString(nowPlaying.Id); + const userId = parseString(session.UserId); + const userImageTag = parseOptionalString(session.UserPrimaryImageTag); + const primaryImageTag = imageTags?.Primary ? parseString(imageTags.Primary) : undefined; + + const result: MediaSession = { + sessionKey: parseString(session.Id), + mediaId: itemId, + user: { + id: userId, + username: parseString(session.UserName), + // Build full path: /Users/{userId}/Images/Primary + thumb: buildUserImagePath(userId, userImageTag), + }, + media: { + title: parseString(nowPlaying.Name), + type: mediaType, + durationMs, + year: parseOptionalNumber(nowPlaying.ProductionYear), + // Build full path: /Items/{itemId}/Images/Primary + thumbPath: buildItemImagePath(itemId, primaryImageTag), + }, + playback: { + state: parsePlaybackState(playState?.IsPaused), + positionMs, + progressPercent: calculateProgress(positionMs, durationMs), + }, + player: { + name: parseString(session.DeviceName), + deviceId: parseString(session.DeviceId), + product: parseOptionalString(session.Client), + device: parseOptionalString(session.DeviceType), + platform: undefined, // Emby doesn't provide platform separately + }, + network: { + ipAddress: parseString(session.RemoteEndPoint), + // Emby doesn't explicitly indicate local vs remote + isLocal: false, + }, + quality: { + bitrate: getBitrate(session), + isTranscode, + videoDecision, + }, + }; + + // Add episode-specific metadata if this is an episode + if (mediaType === 'episode') { + const seriesId = parseOptionalString(nowPlaying.SeriesId); + const seriesImageTag = parseOptionalString(nowPlaying.SeriesPrimaryImageTag); + + result.episode = { + showTitle: parseString(nowPlaying.SeriesName), + showId: seriesId, + seasonNumber: parseNumber(nowPlaying.ParentIndexNumber), + episodeNumber: parseNumber(nowPlaying.IndexNumber), + seasonName: parseOptionalString(nowPlaying.SeasonName), + // Build full path for series poster: /Items/{seriesId}/Images/Primary + showThumbPath: seriesId ? buildItemImagePath(seriesId, seriesImageTag) : undefined, + }; + } + + return result; +} + +/** + * Parse Emby sessions API response + * Filters to only sessions with active playback + */ +export function parseSessionsResponse(sessions: unknown[]): MediaSession[] { + if (!Array.isArray(sessions)) return []; + + const results: MediaSession[] = []; + for (const session of sessions) { + const parsed = parseSession(session as Record); + if (parsed) results.push(parsed); + } + return results; +} + +// ============================================================================ +// User Parsing +// ============================================================================ + +/** + * Parse raw Emby user data into a MediaUser object + */ +export function parseUser(user: Record): MediaUser { + const policy = getNestedObject(user, 'Policy'); + const userId = parseString(user.Id); + const imageTag = parseOptionalString(user.PrimaryImageTag); + + return { + id: userId, + username: parseString(user.Name), + email: undefined, // Emby doesn't expose email in user API + // Build full path for user avatar: /Users/{userId}/Images/Primary + thumb: buildUserImagePath(userId, imageTag), + isAdmin: parseBoolean(policy?.IsAdministrator), + isDisabled: parseBoolean(policy?.IsDisabled), + lastLoginAt: user.LastLoginDate ? new Date(parseString(user.LastLoginDate)) : undefined, + lastActivityAt: user.LastActivityDate ? new Date(parseString(user.LastActivityDate)) : undefined, + }; +} + +/** + * Parse Emby users API response + */ +export function parseUsersResponse(users: unknown[]): MediaUser[] { + if (!Array.isArray(users)) return []; + return users.map((user) => parseUser(user as Record)); +} + +// ============================================================================ +// Library Parsing +// ============================================================================ + +/** + * Parse raw Emby library (virtual folder) data into a MediaLibrary object + */ +export function parseLibrary(folder: Record): MediaLibrary { + return { + id: parseString(folder.ItemId), + name: parseString(folder.Name), + type: parseString(folder.CollectionType, 'unknown'), + locations: Array.isArray(folder.Locations) ? (folder.Locations as string[]) : [], + }; +} + +/** + * Parse Emby libraries (virtual folders) API response + */ +export function parseLibrariesResponse(folders: unknown[]): MediaLibrary[] { + if (!Array.isArray(folders)) return []; + return folders.map((folder) => parseLibrary(folder as Record)); +} + +// ============================================================================ +// Watch History Parsing +// ============================================================================ + +/** + * Parse raw Emby watch history item into a MediaWatchHistoryItem object + */ +export function parseWatchHistoryItem(item: Record): MediaWatchHistoryItem { + const userData = getNestedObject(item, 'UserData'); + const mediaType = parseMediaType(item.Type); + + const historyItem: MediaWatchHistoryItem = { + mediaId: parseString(item.Id), + title: parseString(item.Name), + type: mediaType === 'photo' ? 'unknown' : mediaType, + // Emby returns ISO date string + watchedAt: parseDateString(userData?.LastPlayedDate) ?? '', + playCount: parseNumber(userData?.PlayCount), + }; + + // Add episode metadata if applicable + if (mediaType === 'episode') { + historyItem.episode = { + showTitle: parseString(item.SeriesName), + seasonNumber: parseOptionalNumber(item.ParentIndexNumber), + episodeNumber: parseOptionalNumber(item.IndexNumber), + }; + } + + return historyItem; +} + +/** + * Parse Emby watch history (Items) API response + */ +export function parseWatchHistoryResponse(data: unknown): MediaWatchHistoryItem[] { + const items = (data as { Items?: unknown[] })?.Items; + if (!Array.isArray(items)) return []; + return items.map((item) => parseWatchHistoryItem(item as Record)); +} + +// ============================================================================ +// Activity Log Parsing +// ============================================================================ + +/** + * Activity log entry from Emby + */ +export interface EmbyActivityEntry { + id: number; + name: string; + overview?: string; + shortOverview?: string; + type: string; + itemId?: string; + userId?: string; + date: string; + severity: string; +} + +/** + * Parse raw Emby activity log item + */ +export function parseActivityLogItem(item: Record): EmbyActivityEntry { + return { + id: parseNumber(item.Id), + name: parseString(item.Name), + overview: parseOptionalString(item.Overview), + shortOverview: parseOptionalString(item.ShortOverview), + type: parseString(item.Type), + itemId: parseOptionalString(item.ItemId), + userId: parseOptionalString(item.UserId), + date: parseString(item.Date), + severity: parseString(item.Severity, 'Information'), + }; +} + +/** + * Parse Emby activity log API response + */ +export function parseActivityLogResponse(data: unknown): EmbyActivityEntry[] { + const items = (data as { Items?: unknown[] })?.Items; + if (!Array.isArray(items)) return []; + return items.map((item) => parseActivityLogItem(item as Record)); +} + +// ============================================================================ +// Authentication Response Parsing +// ============================================================================ + +/** + * Authentication result from Emby + */ +export interface EmbyAuthResult { + id: string; + username: string; + token: string; + serverId: string; + isAdmin: boolean; +} + +/** + * Parse Emby authentication response + */ +export function parseAuthResponse(data: Record): EmbyAuthResult { + const user = getNestedObject(data, 'User') ?? {}; + const policy = getNestedObject(user, 'Policy') ?? {}; + + return { + id: parseString(user.Id), + username: parseString(user.Name), + token: parseString(data.AccessToken), + serverId: parseString(data.ServerId), + isAdmin: parseBoolean(policy.IsAdministrator), + }; +} diff --git a/apps/server/src/services/mediaServer/index.ts b/apps/server/src/services/mediaServer/index.ts new file mode 100644 index 0000000..3ecfedc --- /dev/null +++ b/apps/server/src/services/mediaServer/index.ts @@ -0,0 +1,113 @@ +/** + * Media Server Client Module + * + * Provides a unified interface for Plex and Jellyfin media server integrations. + * Use the factory function to create clients based on server type. + * + * @example + * import { createMediaServerClient, type IMediaServerClient } from './services/mediaServer'; + * + * const client = createMediaServerClient({ + * type: 'plex', + * url: 'http://plex.local:32400', + * token: 'encrypted-token', + * }); + * + * const sessions = await client.getSessions(); + * const users = await client.getUsers(); + */ + +import { PlexClient } from './plex/client.js'; +import { JellyfinClient } from './jellyfin/client.js'; +import { EmbyClient } from './emby/client.js'; +import type { + IMediaServerClient, + IMediaServerClientWithHistory, + MediaServerConfig, + CreateClientOptions, +} from './types.js'; + +// ============================================================================ +// Factory Function +// ============================================================================ + +/** + * Create a media server client for the specified server type + * + * @param options - Client configuration including server type, URL, and token + * @returns A media server client implementing IMediaServerClient + * @throws Error if unknown server type is provided + * + * @example + * const client = createMediaServerClient({ + * type: server.type, + * url: server.url, + * token: server.token, + * }); + * + * // Use polymorphically + * const sessions = await client.getSessions(); + */ +export function createMediaServerClient(options: CreateClientOptions): IMediaServerClient { + const config: MediaServerConfig = { + url: options.url, + token: options.token, + id: options.id, + name: options.name, + }; + + switch (options.type) { + case 'plex': + return new PlexClient(config); + case 'jellyfin': + return new JellyfinClient(config); + case 'emby': + return new EmbyClient(config); + default: + throw new Error(`Unknown media server type: ${options.type as string}`); + } +} + +/** + * Type guard to check if a client supports watch history + */ +export function supportsWatchHistory( + client: IMediaServerClient +): client is IMediaServerClientWithHistory { + return 'getWatchHistory' in client && typeof (client as IMediaServerClientWithHistory).getWatchHistory === 'function'; +} + +// ============================================================================ +// Re-exports +// ============================================================================ + +// Types +export type { + IMediaServerClient, + IMediaServerClientWithHistory, + MediaServerConfig, + CreateClientOptions, + MediaSession, + MediaUser, + MediaLibrary, + MediaWatchHistoryItem, +} from './types.js'; + +// Clients (for static method access and direct instantiation) +export { PlexClient } from './plex/client.js'; +export { JellyfinClient } from './jellyfin/client.js'; +export { EmbyClient } from './emby/client.js'; + +// Plex-specific types +export type { PlexServerResource, PlexServerConnection } from './plex/parser.js'; + +// Jellyfin-specific types +export type { JellyfinActivityEntry, JellyfinAuthResult } from './jellyfin/parser.js'; + +// Emby-specific types +export type { EmbyActivityEntry, EmbyAuthResult } from './emby/parser.js'; + +// Parsers (for testing and direct use) +export * as plexParser from './plex/parser.js'; +export * as jellyfinParser from './jellyfin/parser.js'; +export * as embyParser from './emby/parser.js'; diff --git a/apps/server/src/services/mediaServer/jellyfin/client.ts b/apps/server/src/services/mediaServer/jellyfin/client.ts new file mode 100644 index 0000000..ce2a7b4 --- /dev/null +++ b/apps/server/src/services/mediaServer/jellyfin/client.ts @@ -0,0 +1,342 @@ +/** + * Jellyfin Media Server Client + * + * Implements IMediaServerClient for Jellyfin servers. + * Provides a unified interface for session tracking, user management, and library access. + */ + +import { fetchJson, jellyfinHeaders } from '../../../utils/http.js'; +import type { + IMediaServerClient, + IMediaServerClientWithHistory, + MediaSession, + MediaUser, + MediaLibrary, + MediaWatchHistoryItem, + MediaServerConfig, +} from '../types.js'; +import { + parseSessionsResponse, + parseUsersResponse, + parseLibrariesResponse, + parseWatchHistoryResponse, + parseActivityLogResponse, + parseAuthResponse, + parseUser, + type JellyfinActivityEntry, + type JellyfinAuthResult, +} from './parser.js'; + +const CLIENT_NAME = 'Tracearr'; +const CLIENT_VERSION = '1.0.0'; +const DEVICE_ID = 'tracearr-server'; +const DEVICE_NAME = 'Tracearr Server'; + +/** + * Jellyfin Media Server client implementation + * + * @example + * const client = new JellyfinClient({ url: 'http://jellyfin.local:8096', token: 'xxx' }); + * const sessions = await client.getSessions(); + */ +export class JellyfinClient implements IMediaServerClient, IMediaServerClientWithHistory { + public readonly serverType = 'jellyfin' as const; + + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(config: MediaServerConfig) { + this.baseUrl = config.url.replace(/\/$/, ''); + this.apiKey = config.token; + } + + /** + * Build X-Emby-Authorization header value + */ + private buildAuthHeader(): string { + return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}", Token="${this.apiKey}"`; + } + + /** + * Build headers for Jellyfin API requests + */ + private buildHeaders(): Record { + return { + 'X-Emby-Authorization': this.buildAuthHeader(), + ...jellyfinHeaders(), + }; + } + + // ========================================================================== + // IMediaServerClient Implementation + // ========================================================================== + + /** + * Get all active playback sessions + */ + async getSessions(): Promise { + const data = await fetchJson(`${this.baseUrl}/Sessions`, { + headers: this.buildHeaders(), + service: 'jellyfin', + timeout: 10000, // 10s timeout to prevent polling hangs + }); + + return parseSessionsResponse(data); + } + + /** + * Get all users on this server + */ + async getUsers(): Promise { + const data = await fetchJson(`${this.baseUrl}/Users`, { + headers: this.buildHeaders(), + service: 'jellyfin', + }); + + return parseUsersResponse(data); + } + + /** + * Get all libraries on this server + */ + async getLibraries(): Promise { + const data = await fetchJson(`${this.baseUrl}/Library/VirtualFolders`, { + headers: this.buildHeaders(), + service: 'jellyfin', + }); + + return parseLibrariesResponse(data); + } + + /** + * Test connection to the server + */ + async testConnection(): Promise { + try { + await fetchJson(`${this.baseUrl}/System/Info`, { + headers: this.buildHeaders(), + service: 'jellyfin', + timeout: 10000, + }); + return true; + } catch { + return false; + } + } + + // ========================================================================== + // IMediaServerClientWithHistory Implementation + // ========================================================================== + + /** + * Get watch history for a specific user + * + * Note: Unlike Tautulli, this only returns WHAT was watched, not session details. + * For full session history, users would need Jellystat or the Playback Reporting plugin. + */ + async getWatchHistory(options?: { + userId?: string; + limit?: number; + }): Promise { + if (!options?.userId) { + throw new Error('Jellyfin requires a userId for watch history'); + } + + const params = new URLSearchParams({ + Recursive: 'true', + IncludeItemTypes: 'Movie,Episode', + Filters: 'IsPlayed', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: String(options.limit ?? 500), + Fields: 'MediaSources', + }); + + const data = await fetchJson( + `${this.baseUrl}/Users/${options.userId}/Items?${params}`, + { + headers: this.buildHeaders(), + service: 'jellyfin', + } + ); + + return parseWatchHistoryResponse(data); + } + + // ========================================================================== + // Session Control + // ========================================================================== + + /** + * Terminate a playback session by sending a Stop command + * + * @param sessionId - The session ID (same as sessionKey for Jellyfin) + * @param _reason - Ignored (Jellyfin doesn't support user-facing messages) + * @returns true if successful, throws on error + * + * @example + * await client.terminateSession('session-uuid-123'); + */ + async terminateSession(sessionId: string, _reason?: string): Promise { + const response = await fetch(`${this.baseUrl}/Sessions/${sessionId}/Playing/Stop`, { + method: 'POST', + headers: this.buildHeaders(), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized to terminate session'); + } + if (response.status === 404) { + throw new Error('Session not found (may have already ended)'); + } + throw new Error(`Failed to terminate session: ${response.status} ${response.statusText}`); + } + + return true; + } + + // ========================================================================== + // Jellyfin-Specific Methods + // ========================================================================== + + /** + * Get watch history for all users on the server + */ + async getAllUsersWatchHistory( + limit = 200 + ): Promise> { + const allUsers = await this.getUsers(); + const historyMap = new Map(); + + for (const user of allUsers) { + if (user.isDisabled) continue; + try { + const history = await this.getWatchHistory({ userId: user.id, limit }); + historyMap.set(user.id, history); + } catch (error) { + console.error(`Failed to get history for user ${user.username}:`, error); + } + } + + return historyMap; + } + + /** + * Get activity log entries (requires admin) + * + * Activity types to watch for: + * - AuthenticationSucceeded - Successful login + * - AuthenticationFailed - Failed login attempt + * - SessionStarted - New session + * - SessionEnded - Session ended + */ + async getActivityLog(options?: { + minDate?: Date; + limit?: number; + hasUserId?: boolean; + }): Promise { + const params = new URLSearchParams(); + if (options?.limit) params.append('limit', String(options.limit)); + if (options?.minDate) params.append('minDate', options.minDate.toISOString()); + if (options?.hasUserId !== undefined) params.append('hasUserId', String(options.hasUserId)); + + const data = await fetchJson( + `${this.baseUrl}/System/ActivityLog/Entries?${params}`, + { + headers: this.buildHeaders(), + service: 'jellyfin', + } + ); + + return parseActivityLogResponse(data); + } + + // ========================================================================== + // Static Methods - Authentication + // ========================================================================== + + /** + * Authenticate with username/password + */ + static async authenticate( + serverUrl: string, + username: string, + password: string + ): Promise { + const url = serverUrl.replace(/\/$/, ''); + const authHeader = `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}"`; + + try { + const data = await fetchJson>( + `${url}/Users/AuthenticateByName`, + { + method: 'POST', + headers: { + 'X-Emby-Authorization': authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + Username: username, + Pw: password, + }), + service: 'jellyfin', + } + ); + + return parseAuthResponse(data); + } catch (error) { + // Return null for auth failures, rethrow other errors + if (error instanceof Error && error.message.includes('401')) { + return null; + } + throw error; + } + } + + /** + * Verify if token has admin access to a Jellyfin server + * + * Handles two token types: + * 1. User tokens (from AuthenticateByName) - verified via /Users/Me + * 2. API keys (created in Jellyfin admin) - verified via /Auth/Keys (requires admin) + */ + static async verifyServerAdmin(apiKey: string, serverUrl: string): Promise { + const url = serverUrl.replace(/\/$/, ''); + const authHeader = `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${DEVICE_ID}", Version="${CLIENT_VERSION}", Token="${apiKey}"`; + + const headers = { + 'X-Emby-Authorization': authHeader, + Accept: 'application/json', + }; + + // Try /Users/Me first (works for user tokens from authentication) + try { + const data = await fetchJson>(`${url}/Users/Me`, { + headers, + service: 'jellyfin', + timeout: 10000, + }); + + const user = parseUser(data); + return user.isAdmin; + } catch { + // /Users/Me returns 400 for API keys (not user tokens) + // Fall through to try /Auth/Keys + } + + // Try /Auth/Keys (only accessible with admin-level API keys) + try { + await fetchJson(`${url}/Auth/Keys`, { + headers, + service: 'jellyfin', + timeout: 10000, + }); + // If we can access /Auth/Keys, the token has admin access + return true; + } catch { + return false; + } + } +} diff --git a/apps/server/src/services/mediaServer/jellyfin/parser.ts b/apps/server/src/services/mediaServer/jellyfin/parser.ts new file mode 100644 index 0000000..af76c06 --- /dev/null +++ b/apps/server/src/services/mediaServer/jellyfin/parser.ts @@ -0,0 +1,474 @@ +/** + * Jellyfin API Response Parser + * + * Pure functions for parsing raw Jellyfin API responses into typed objects. + * Separated from the client for testability and reuse. + */ + +import { + parseString, + parseNumber, + parseBoolean, + parseOptionalString, + parseOptionalNumber, + getNestedObject, + getNestedValue, + parseDateString, +} from '../../../utils/parsing.js'; +import type { MediaSession, MediaUser, MediaLibrary, MediaWatchHistoryItem } from '../types.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Jellyfin ticks per millisecond (10,000 ticks = 1ms) */ +const TICKS_PER_MS = 10000; + +// ============================================================================ +// Session Parsing +// ============================================================================ + +/** + * Convert Jellyfin ticks to milliseconds + */ +function ticksToMs(ticks: unknown): number { + const tickNum = parseNumber(ticks); + return Math.floor(tickNum / TICKS_PER_MS); +} + +/** + * Parse Jellyfin media type to unified type + */ +function parseMediaType(type: unknown): MediaSession['media']['type'] { + const typeStr = parseString(type).toLowerCase(); + switch (typeStr) { + case 'movie': + return 'movie'; + case 'episode': + return 'episode'; + case 'audio': + return 'track'; + case 'photo': + return 'photo'; + default: + return 'unknown'; + } +} + +/** + * Parse playback state from Jellyfin to unified state + */ +function parsePlaybackState(isPaused: unknown): MediaSession['playback']['state'] { + return parseBoolean(isPaused) ? 'paused' : 'playing'; +} + +/** + * Calculate progress percentage from position and duration + */ +function calculateProgress(positionMs: number, durationMs: number): number { + if (durationMs <= 0) return 0; + return Math.min(100, Math.round((positionMs / durationMs) * 100)); +} + +/** + * Get bitrate from Jellyfin session in kbps (prefer transcoding bitrate, fall back to source) + * Note: Jellyfin API returns bitrate in bps, so we convert to kbps for consistency with Plex + */ +function getBitrate(session: Record): number { + // Check transcoding info first + const transcodingInfo = getNestedObject(session, 'TranscodingInfo'); + if (transcodingInfo) { + const transcodeBitrate = parseNumber(transcodingInfo.Bitrate); + if (transcodeBitrate > 0) return Math.round(transcodeBitrate / 1000); // bps → kbps + } + + // Fall back to source media bitrate + const nowPlaying = getNestedObject(session, 'NowPlayingItem'); + const mediaSources = nowPlaying?.MediaSources; + if (Array.isArray(mediaSources) && mediaSources.length > 0) { + const firstSource = mediaSources[0] as Record; + const bitrate = parseNumber(firstSource?.Bitrate); + return Math.round(bitrate / 1000); // bps → kbps + } + + return 0; +} + +/** + * Get video height from Jellyfin session for resolution display + * Checks TranscodingInfo first (for transcoded resolution), then falls back to source + */ +function getVideoHeight(session: Record): number | undefined { + // Check transcoding info first for transcoded resolution + const transcodingInfo = getNestedObject(session, 'TranscodingInfo'); + if (transcodingInfo) { + const height = parseOptionalNumber(transcodingInfo.Height); + if (height && height > 0) return height; + } + + // Fall back to source video stream resolution + const nowPlaying = getNestedObject(session, 'NowPlayingItem'); + const mediaSources = nowPlaying?.MediaSources; + if (Array.isArray(mediaSources) && mediaSources.length > 0) { + const firstSource = mediaSources[0] as Record; + const mediaStreams = firstSource?.MediaStreams; + if (Array.isArray(mediaStreams)) { + // Find the video stream (Type === 'Video') + for (const stream of mediaStreams) { + const streamObj = stream as Record; + if (parseOptionalString(streamObj.Type)?.toLowerCase() === 'video') { + const height = parseOptionalNumber(streamObj.Height); + if (height && height > 0) return height; + } + } + } + } + + return undefined; +} + +/** + * Get play method from PlayState and normalize to lowercase + * PlayMethod enum: DirectPlay, DirectStream, Transcode + */ +function getPlayMethod(session: Record): string { + const playState = getNestedObject(session, 'PlayState'); + const playMethod = parseOptionalString(playState?.PlayMethod); + + if (playMethod) { + // Normalize to lowercase: DirectPlay → directplay, DirectStream → directstream, Transcode → transcode + return playMethod.toLowerCase(); + } + + // Fall back to checking TranscodingInfo if PlayMethod not available + const transcodingInfo = getNestedObject(session, 'TranscodingInfo'); + if (!transcodingInfo) return 'directplay'; + + const isVideoDirect = getNestedValue(transcodingInfo, 'IsVideoDirect'); + return isVideoDirect === false ? 'transcode' : 'directplay'; +} + +/** + * Determine if stream is being transcoded + * Uses PlayMethod from PlayState for accuracy, falls back to TranscodingInfo + */ +function _isTranscoding(session: Record): boolean { + const playMethod = getPlayMethod(session); + return playMethod === 'transcode'; +} + +/** + * Check if session is a trailer or preroll that should be filtered out + */ +function isTrailerOrPreroll(nowPlaying: Record): boolean { + // Filter trailers + const itemType = parseOptionalString(nowPlaying.Type); + if (itemType?.toLowerCase() === 'trailer') return true; + + // Filter preroll videos (check ProviderIds for prerolls.video) + const providerIds = getNestedObject(nowPlaying, 'ProviderIds'); + if (providerIds && 'prerolls.video' in providerIds) return true; + + return false; +} + +/** + * Build Jellyfin image URL path for an item + * Jellyfin images use: /Items/{id}/Images/{type} + */ +function buildItemImagePath(itemId: string, imageTag: string | undefined): string | undefined { + if (!imageTag || !itemId) return undefined; + return `/Items/${itemId}/Images/Primary`; +} + +/** + * Build Jellyfin image URL path for a user avatar + * Jellyfin user images use: /Users/{id}/Images/Primary + */ +function buildUserImagePath(userId: string, imageTag: string | undefined): string | undefined { + if (!imageTag || !userId) return undefined; + return `/Users/${userId}/Images/Primary`; +} + +/** + * Parse raw Jellyfin session data into a MediaSession object + */ +export function parseSession(session: Record): MediaSession | null { + const nowPlaying = getNestedObject(session, 'NowPlayingItem'); + if (!nowPlaying) return null; // No active playback + + // Filter out trailers and prerolls + if (isTrailerOrPreroll(nowPlaying)) return null; + + const playState = getNestedObject(session, 'PlayState'); + const imageTags = getNestedObject(nowPlaying, 'ImageTags'); + + const durationMs = ticksToMs(nowPlaying.RunTimeTicks); + const positionMs = ticksToMs(playState?.PositionTicks); + const mediaType = parseMediaType(nowPlaying.Type); + + // Use PlayMethod for accurate transcode detection + const videoDecision = getPlayMethod(session); + const isTranscode = videoDecision === 'transcode'; + + // Parse LastPausedDate for accurate pause tracking + const lastPausedDateStr = parseOptionalString(session.LastPausedDate); + const lastPausedDate = lastPausedDateStr ? new Date(lastPausedDateStr) : undefined; + + // Build full image paths for Jellyfin (not just image tag IDs) + const itemId = parseString(nowPlaying.Id); + const userId = parseString(session.UserId); + const userImageTag = parseOptionalString(session.UserPrimaryImageTag); + const primaryImageTag = imageTags?.Primary ? parseString(imageTags.Primary) : undefined; + + const result: MediaSession = { + sessionKey: parseString(session.Id), + mediaId: itemId, + user: { + id: userId, + username: parseString(session.UserName), + // Build full path: /Users/{userId}/Images/Primary + thumb: buildUserImagePath(userId, userImageTag), + }, + media: { + title: parseString(nowPlaying.Name), + type: mediaType, + durationMs, + year: parseOptionalNumber(nowPlaying.ProductionYear), + // Build full path: /Items/{itemId}/Images/Primary + thumbPath: buildItemImagePath(itemId, primaryImageTag), + }, + playback: { + state: parsePlaybackState(playState?.IsPaused), + positionMs, + progressPercent: calculateProgress(positionMs, durationMs), + }, + player: { + name: parseString(session.DeviceName), + deviceId: parseString(session.DeviceId), + product: parseOptionalString(session.Client), + device: parseOptionalString(session.DeviceType), + platform: undefined, // Jellyfin doesn't provide platform separately + }, + network: { + ipAddress: parseString(session.RemoteEndPoint), + // Jellyfin doesn't explicitly indicate local vs remote + isLocal: false, + }, + quality: { + bitrate: getBitrate(session), + isTranscode, + videoDecision, + videoHeight: getVideoHeight(session), + }, + // Jellyfin provides exact pause timestamp for accurate tracking + lastPausedDate, + }; + + // Add episode-specific metadata if this is an episode + if (mediaType === 'episode') { + const seriesId = parseOptionalString(nowPlaying.SeriesId); + const seriesImageTag = parseOptionalString(nowPlaying.SeriesPrimaryImageTag); + + result.episode = { + showTitle: parseString(nowPlaying.SeriesName), + showId: seriesId, + seasonNumber: parseNumber(nowPlaying.ParentIndexNumber), + episodeNumber: parseNumber(nowPlaying.IndexNumber), + seasonName: parseOptionalString(nowPlaying.SeasonName), + // Build full path for series poster: /Items/{seriesId}/Images/Primary + showThumbPath: seriesId ? buildItemImagePath(seriesId, seriesImageTag) : undefined, + }; + } + + return result; +} + +/** + * Parse Jellyfin sessions API response + * Filters to only sessions with active playback + */ +export function parseSessionsResponse(sessions: unknown[]): MediaSession[] { + if (!Array.isArray(sessions)) return []; + + const results: MediaSession[] = []; + for (const session of sessions) { + const parsed = parseSession(session as Record); + if (parsed) results.push(parsed); + } + return results; +} + +// ============================================================================ +// User Parsing +// ============================================================================ + +/** + * Parse raw Jellyfin user data into a MediaUser object + */ +export function parseUser(user: Record): MediaUser { + const policy = getNestedObject(user, 'Policy'); + const userId = parseString(user.Id); + const imageTag = parseOptionalString(user.PrimaryImageTag); + + return { + id: userId, + username: parseString(user.Name), + email: undefined, // Jellyfin doesn't expose email in user API + // Build full path for user avatar: /Users/{userId}/Images/Primary + thumb: buildUserImagePath(userId, imageTag), + isAdmin: parseBoolean(policy?.IsAdministrator), + isDisabled: parseBoolean(policy?.IsDisabled), + lastLoginAt: user.LastLoginDate ? new Date(parseString(user.LastLoginDate)) : undefined, + lastActivityAt: user.LastActivityDate ? new Date(parseString(user.LastActivityDate)) : undefined, + }; +} + +/** + * Parse Jellyfin users API response + */ +export function parseUsersResponse(users: unknown[]): MediaUser[] { + if (!Array.isArray(users)) return []; + return users.map((user) => parseUser(user as Record)); +} + +// ============================================================================ +// Library Parsing +// ============================================================================ + +/** + * Parse raw Jellyfin library (virtual folder) data into a MediaLibrary object + */ +export function parseLibrary(folder: Record): MediaLibrary { + return { + id: parseString(folder.ItemId), + name: parseString(folder.Name), + type: parseString(folder.CollectionType, 'unknown'), + locations: Array.isArray(folder.Locations) ? (folder.Locations as string[]) : [], + }; +} + +/** + * Parse Jellyfin libraries (virtual folders) API response + */ +export function parseLibrariesResponse(folders: unknown[]): MediaLibrary[] { + if (!Array.isArray(folders)) return []; + return folders.map((folder) => parseLibrary(folder as Record)); +} + +// ============================================================================ +// Watch History Parsing +// ============================================================================ + +/** + * Parse raw Jellyfin watch history item into a MediaWatchHistoryItem object + */ +export function parseWatchHistoryItem(item: Record): MediaWatchHistoryItem { + const userData = getNestedObject(item, 'UserData'); + const mediaType = parseMediaType(item.Type); + + const historyItem: MediaWatchHistoryItem = { + mediaId: parseString(item.Id), + title: parseString(item.Name), + type: mediaType === 'photo' ? 'unknown' : mediaType, + // Jellyfin returns ISO date string + watchedAt: parseDateString(userData?.LastPlayedDate) ?? '', + playCount: parseNumber(userData?.PlayCount), + }; + + // Add episode metadata if applicable + if (mediaType === 'episode') { + historyItem.episode = { + showTitle: parseString(item.SeriesName), + seasonNumber: parseOptionalNumber(item.ParentIndexNumber), + episodeNumber: parseOptionalNumber(item.IndexNumber), + }; + } + + return historyItem; +} + +/** + * Parse Jellyfin watch history (Items) API response + */ +export function parseWatchHistoryResponse(data: unknown): MediaWatchHistoryItem[] { + const items = (data as { Items?: unknown[] })?.Items; + if (!Array.isArray(items)) return []; + return items.map((item) => parseWatchHistoryItem(item as Record)); +} + +// ============================================================================ +// Activity Log Parsing +// ============================================================================ + +/** + * Activity log entry from Jellyfin + */ +export interface JellyfinActivityEntry { + id: number; + name: string; + overview?: string; + shortOverview?: string; + type: string; + itemId?: string; + userId?: string; + date: string; + severity: string; +} + +/** + * Parse raw Jellyfin activity log item + */ +export function parseActivityLogItem(item: Record): JellyfinActivityEntry { + return { + id: parseNumber(item.Id), + name: parseString(item.Name), + overview: parseOptionalString(item.Overview), + shortOverview: parseOptionalString(item.ShortOverview), + type: parseString(item.Type), + itemId: parseOptionalString(item.ItemId), + userId: parseOptionalString(item.UserId), + date: parseString(item.Date), + severity: parseString(item.Severity, 'Information'), + }; +} + +/** + * Parse Jellyfin activity log API response + */ +export function parseActivityLogResponse(data: unknown): JellyfinActivityEntry[] { + const items = (data as { Items?: unknown[] })?.Items; + if (!Array.isArray(items)) return []; + return items.map((item) => parseActivityLogItem(item as Record)); +} + +// ============================================================================ +// Authentication Response Parsing +// ============================================================================ + +/** + * Authentication result from Jellyfin + */ +export interface JellyfinAuthResult { + id: string; + username: string; + token: string; + serverId: string; + isAdmin: boolean; +} + +/** + * Parse Jellyfin authentication response + */ +export function parseAuthResponse(data: Record): JellyfinAuthResult { + const user = getNestedObject(data, 'User') ?? {}; + const policy = getNestedObject(user, 'Policy') ?? {}; + + return { + id: parseString(user.Id), + username: parseString(user.Name), + token: parseString(data.AccessToken), + serverId: parseString(data.ServerId), + isAdmin: parseBoolean(policy.IsAdministrator), + }; +} diff --git a/apps/server/src/services/mediaServer/plex/client.ts b/apps/server/src/services/mediaServer/plex/client.ts new file mode 100644 index 0000000..dd6e7ac --- /dev/null +++ b/apps/server/src/services/mediaServer/plex/client.ts @@ -0,0 +1,421 @@ +/** + * Plex Media Server Client + * + * Implements IMediaServerClient for Plex servers. + * Provides a unified interface for session tracking, user management, and library access. + */ + +import { fetchJson, fetchText, plexHeaders } from '../../../utils/http.js'; +import type { + IMediaServerClient, + IMediaServerClientWithHistory, + MediaSession, + MediaUser, + MediaLibrary, + MediaWatchHistoryItem, + MediaServerConfig, +} from '../types.js'; +import { + parseSessionsResponse, + parseUsersResponse, + parseLibrariesResponse, + parseWatchHistoryResponse, + parseServerResourcesResponse, + parsePlexTvUser, + parseXmlUsersResponse, + parseSharedServersXml, + parseStatisticsResourcesResponse, + type PlexServerResource, + type PlexStatisticsDataPoint, +} from './parser.js'; + +const PLEX_TV_BASE = 'https://plex.tv'; + +/** + * Plex Media Server client implementation + * + * @example + * const client = new PlexClient({ url: 'http://plex.local:32400', token: 'xxx' }); + * const sessions = await client.getSessions(); + */ +export class PlexClient implements IMediaServerClient, IMediaServerClientWithHistory { + public readonly serverType = 'plex' as const; + + private readonly baseUrl: string; + private readonly token: string; + + constructor(config: MediaServerConfig) { + this.baseUrl = config.url.replace(/\/$/, ''); + this.token = config.token; + } + + /** + * Build headers for Plex API requests + */ + private buildHeaders(): Record { + return plexHeaders(this.token); + } + + // ========================================================================== + // IMediaServerClient Implementation + // ========================================================================== + + /** + * Get all active playback sessions + */ + async getSessions(): Promise { + const data = await fetchJson(`${this.baseUrl}/status/sessions`, { + headers: this.buildHeaders(), + service: 'plex', + timeout: 10000, // 10s timeout to prevent polling hangs + }); + + return parseSessionsResponse(data); + } + + /** + * Get all local users (accounts from /accounts endpoint) + * + * Note: For complete user lists including shared users, + * use PlexClient.getAllUsersWithLibraries() static method. + */ + async getUsers(): Promise { + const data = await fetchJson(`${this.baseUrl}/accounts`, { + headers: this.buildHeaders(), + service: 'plex', + }); + + return parseUsersResponse(data); + } + + /** + * Get all libraries on this server + */ + async getLibraries(): Promise { + const data = await fetchJson(`${this.baseUrl}/library/sections`, { + headers: this.buildHeaders(), + service: 'plex', + }); + + return parseLibrariesResponse(data); + } + + /** + * Test connection to the server + */ + async testConnection(): Promise { + try { + await fetchJson(`${this.baseUrl}/`, { + headers: this.buildHeaders(), + service: 'plex', + timeout: 10000, + }); + return true; + } catch { + return false; + } + } + + // ========================================================================== + // IMediaServerClientWithHistory Implementation + // ========================================================================== + + /** + * Get watch history from server + */ + async getWatchHistory(options?: { + userId?: string; + limit?: number; + }): Promise { + const limit = options?.limit ?? 100; + const uri = `/status/sessions/history/all?X-Plex-Container-Start=0&X-Plex-Container-Size=${limit}`; + + const data = await fetchJson(`${this.baseUrl}${uri}`, { + headers: this.buildHeaders(), + service: 'plex', + }); + + return parseWatchHistoryResponse(data); + } + + // ========================================================================== + // Session Control + // ========================================================================== + + /** + * Terminate a playback session + * + * Requires Plex Pass subscription on the server. + * + * @param sessionId - The Session.id from the sessions API (NOT sessionKey!) + * @param reason - Optional message displayed to the user in their client + * @returns true if successful, throws on error + * + * @example + * await client.terminateSession('abc123xyz', 'Concurrent stream limit exceeded'); + */ + async terminateSession(sessionId: string, reason?: string): Promise { + const params = new URLSearchParams({ sessionId }); + if (reason) { + params.set('reason', reason); + } + + const response = await fetch(`${this.baseUrl}/status/sessions/terminate?${params}`, { + method: 'POST', + headers: this.buildHeaders(), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Plex Pass subscription required for stream termination'); + } + if (response.status === 403) { + throw new Error('Invalid or empty session ID'); + } + if (response.status === 404) { + throw new Error('Session not found (may have already ended)'); + } + throw new Error(`Failed to terminate session: ${response.status} ${response.statusText}`); + } + + return true; + } + + // ========================================================================== + // Server Resource Statistics (Undocumented Endpoint) + // ========================================================================== + + /** + * Get server resource statistics (CPU, RAM utilization) + * + * Uses the undocumented /statistics/resources endpoint. + * Returns ~27 data points covering ~2.5 minutes of history at 6-second intervals. + * + * @param timespan - Interval between data points in seconds (default: 6) + * @returns Array of resource data points, sorted newest first + */ + async getServerStatistics(timespan: number = 6): Promise { + const url = `${this.baseUrl}/statistics/resources?timespan=${timespan}`; + + const data = await fetchJson(url, { + headers: this.buildHeaders(), + service: 'plex', + timeout: 10000, + }); + + return parseStatisticsResourcesResponse(data); + } + + // ========================================================================== + // Static Methods - Plex.tv API Operations + // ========================================================================== + + /** + * Initiate OAuth flow for Plex authentication + * Returns a PIN ID and auth URL for user to authorize + * @param forwardUrl - URL to redirect to after auth (for popup auto-close) + */ + static async initiateOAuth(forwardUrl?: string): Promise<{ pinId: string; authUrl: string }> { + const headers = plexHeaders(); + + const data = await fetchJson<{ id: number; code: string }>( + `${PLEX_TV_BASE}/api/v2/pins`, + { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ strong: 'true' }), + service: 'plex.tv', + } + ); + + const params = new URLSearchParams({ + clientID: 'tracearr', + code: data.code, + 'context[device][product]': 'Tracearr', + }); + + if (forwardUrl) { + params.set('forwardUrl', forwardUrl); + } + + const authUrl = `https://app.plex.tv/auth#?${params.toString()}`; + + return { + pinId: String(data.id), + authUrl, + }; + } + + /** + * Check if OAuth PIN has been authorized + * Returns auth result if authorized, null if still pending + */ + static async checkOAuthPin(pinId: string): Promise<{ + id: string; + username: string; + email: string; + thumb: string; + token: string; + } | null> { + const headers = plexHeaders(); + + const pin = await fetchJson<{ authToken: string | null }>( + `${PLEX_TV_BASE}/api/v2/pins/${pinId}`, + { headers, service: 'plex.tv' } + ); + + if (!pin.authToken) { + return null; + } + + // Fetch user info with the token + const user = await fetchJson>( + `${PLEX_TV_BASE}/api/v2/user`, + { + headers: plexHeaders(pin.authToken), + service: 'plex.tv', + } + ); + + return { + id: String(user.id ?? ''), + username: String(user.username ?? ''), + email: String(user.email ?? ''), + thumb: String(user.thumb ?? ''), + token: pin.authToken, + }; + } + + /** + * Verify if token has admin access to a Plex server + */ + static async verifyServerAdmin(token: string, serverUrl: string): Promise { + const url = serverUrl.replace(/\/$/, ''); + const headers = plexHeaders(token); + + try { + // First verify basic server access + await fetchJson(`${url}/`, { + headers, + service: 'plex', + timeout: 10000, + }); + + // Then verify admin access by fetching accounts + await fetchJson(`${url}/accounts`, { + headers, + service: 'plex', + timeout: 10000, + }); + + return true; + } catch { + return false; + } + } + + /** + * Get user's owned Plex servers from plex.tv + */ + static async getServers(token: string): Promise { + const data = await fetchJson(`${PLEX_TV_BASE}/api/v2/resources`, { + headers: plexHeaders(token), + service: 'plex.tv', + }); + + return parseServerResourcesResponse(data, token); + } + + /** + * Get owner account info from plex.tv + */ + static async getAccountInfo(token: string): Promise { + const user = await fetchJson>( + `${PLEX_TV_BASE}/api/v2/user`, + { + headers: plexHeaders(token), + service: 'plex.tv', + } + ); + + return parsePlexTvUser( + { + ...user, + isAdmin: true, + }, + [] // Owner has access to all libraries + ); + } + + /** + * Get all shared users from plex.tv (XML endpoint) + */ + static async getFriends(token: string): Promise { + const headers = { + ...plexHeaders(token), + Accept: 'application/xml', + }; + + const xml = await fetchText(`${PLEX_TV_BASE}/api/users`, { + headers, + service: 'plex.tv', + }); + + return parseXmlUsersResponse(xml); + } + + /** + * Get shared server info (server_token and shared_libraries per user) + */ + static async getSharedServerUsers( + token: string, + machineIdentifier: string + ): Promise> { + const headers = { + ...plexHeaders(token), + Accept: 'application/xml', + }; + + try { + const xml = await fetchText( + `${PLEX_TV_BASE}/api/servers/${machineIdentifier}/shared_servers`, + { headers, service: 'plex.tv' } + ); + + return parseSharedServersXml(xml); + } catch { + // Return empty map if endpoint fails + return new Map(); + } + } + + /** + * Get all users with access to a specific server + * Combines /api/users + /api/servers/{id}/shared_servers + */ + static async getAllUsersWithLibraries( + token: string, + machineIdentifier: string + ): Promise { + const [owner, allFriends, sharedServerMap] = await Promise.all([ + PlexClient.getAccountInfo(token), + PlexClient.getFriends(token), + PlexClient.getSharedServerUsers(token, machineIdentifier), + ]); + + // Enrich friends with shared_libraries from shared_servers + // Only include users who have access to THIS server + const usersWithAccess = allFriends + .filter((friend) => sharedServerMap.has(friend.id)) + .map((friend) => ({ + ...friend, + sharedLibraries: sharedServerMap.get(friend.id)?.sharedLibraries ?? [], + })); + + // Owner always has access to all libraries + return [owner, ...usersWithAccess]; + } +} diff --git a/apps/server/src/services/mediaServer/plex/eventSource.ts b/apps/server/src/services/mediaServer/plex/eventSource.ts new file mode 100644 index 0000000..6474be6 --- /dev/null +++ b/apps/server/src/services/mediaServer/plex/eventSource.ts @@ -0,0 +1,399 @@ +/** + * Plex EventSource Service + * + * Handles Server-Sent Events (SSE) connections to Plex servers for real-time + * session notifications. This replaces aggressive polling with instant updates. + * + * Plex exposes SSE at: /:/eventsource/notifications + * + * Event types we care about: + * - playing: Session started or resumed + * - paused: Session paused + * - stopped: Session ended + * - progress: Playback position updated + */ + +import { EventEmitter } from 'events'; +import { + SSE_CONFIG, + type PlexSSENotification, + type PlexPlaySessionNotification, + type SSEConnectionState, +} from '@tracearr/shared'; +import { plexHeaders } from '../../../utils/http.js'; + +// EventSource types for Node.js (using eventsource package) +interface EventSourceMessage { + data: string; + lastEventId?: string; + origin?: string; +} + +type EventSourceReadyState = 0 | 1 | 2; + +interface EventSourceInit { + headers?: Record; + withCredentials?: boolean; +} + +// Dynamic import of eventsource package +let EventSourceClass: new (url: string, init?: EventSourceInit) => EventSource; + +interface EventSource { + readonly readyState: EventSourceReadyState; + readonly url: string; + onopen: ((this: EventSource, ev: Event) => void) | null; + onmessage: ((this: EventSource, ev: EventSourceMessage) => void) | null; + onerror: ((this: EventSource, ev: Event) => void) | null; + close(): void; + addEventListener(type: string, listener: (ev: EventSourceMessage) => void): void; + removeEventListener(type: string, listener: (ev: EventSourceMessage) => void): void; +} + +// Event types emitted by PlexEventSource +export interface PlexEventSourceEvents { + 'session:playing': PlexPlaySessionNotification; + 'session:paused': PlexPlaySessionNotification; + 'session:stopped': PlexPlaySessionNotification; + 'session:progress': PlexPlaySessionNotification; + 'connection:state': SSEConnectionState; + 'connection:error': Error; +} + +/** + * PlexEventSource - Manages SSE connection to a Plex server + * + * @example + * const sse = new PlexEventSource({ + * serverId: 'abc123', + * url: 'http://plex.local:32400', + * token: 'encrypted-token', + * }); + * + * sse.on('session:playing', (notification) => { + * console.log('Session started:', notification.sessionKey); + * }); + * + * await sse.connect(); + */ +export class PlexEventSource extends EventEmitter { + private readonly serverId: string; + private readonly serverName: string; + private readonly baseUrl: string; + private readonly token: string; + + private eventSource: EventSource | null = null; + private state: SSEConnectionState = 'disconnected'; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + private lastEventTime: Date | null = null; + private connectedAt: Date | null = null; + private lastError: Error | null = null; + + constructor(config: { + serverId: string; + serverName: string; + url: string; + token: string; + }) { + super(); + this.serverId = config.serverId; + this.serverName = config.serverName; + this.baseUrl = config.url.replace(/\/$/, ''); + this.token = config.token; + } + + /** + * Get current connection state + */ + getState(): SSEConnectionState { + return this.state; + } + + /** + * Get connection status for monitoring + */ + getStatus(): { + serverId: string; + serverName: string; + state: SSEConnectionState; + connectedAt: Date | null; + lastEventAt: Date | null; + reconnectAttempts: number; + error: string | null; + } { + return { + serverId: this.serverId, + serverName: this.serverName, + state: this.state, + connectedAt: this.connectedAt, + lastEventAt: this.lastEventTime, + reconnectAttempts: this.reconnectAttempts, + error: this.lastError?.message ?? null, + }; + } + + /** + * Connect to Plex SSE endpoint + */ + async connect(): Promise { + // Lazy load eventsource package + if (!EventSourceClass) { + const module = await import('eventsource'); + // eventsource v4 exports EventSource as a named export + EventSourceClass = module.EventSource as unknown as typeof EventSourceClass; + } + + if (this.state === 'connected' || this.state === 'connecting') { + return; + } + + this.setState('connecting'); + this.clearTimers(); + + try { + // Plex SSE requires token as query param (headers may not work with EventSource) + const url = `${this.baseUrl}/:/eventsource/notifications?X-Plex-Token=${encodeURIComponent(this.token)}`; + const headers = plexHeaders(this.token); + + console.log(`[SSE] Connecting to ${this.serverName} at ${this.baseUrl}/:/eventsource/notifications`); + + this.eventSource = new EventSourceClass(url, { + headers, + }); + + this.eventSource.onopen = () => { + console.log(`[SSE] Connected to ${this.serverName}`); + this.setState('connected'); + this.connectedAt = new Date(); + this.reconnectAttempts = 0; + this.lastError = null; + this.startHeartbeatMonitor(); + }; + + // eventsource v4 requires addEventListener instead of onmessage + // Plex sends named 'playing' events for all playback notifications + this.eventSource.addEventListener('message', (event: EventSourceMessage) => { + this.handleMessage(event); + }); + + this.eventSource.addEventListener('playing', (event: EventSourceMessage) => { + this.handleMessage(event); + }); + + this.eventSource.addEventListener('notification', (event: EventSourceMessage) => { + this.handleMessage(event); + }); + + this.eventSource.onerror = (error: Event) => { + this.handleError(error); + }; + } catch (error) { + this.handleError(error as Error); + } + } + + /** + * Disconnect from SSE + */ + disconnect(): void { + console.log(`[SSE] Disconnecting from ${this.serverName}`); + this.clearTimers(); + + if (this.eventSource) { + this.eventSource.onopen = null; + this.eventSource.onerror = null; + // Note: addEventListener listeners are cleaned up when close() is called + this.eventSource.close(); + this.eventSource = null; + } + + this.setState('disconnected'); + this.connectedAt = null; + } + + /** + * Handle incoming SSE message + */ + private handleMessage(event: EventSourceMessage): void { + this.lastEventTime = new Date(); + this.resetHeartbeatMonitor(); + + if (!event.data) { + return; + } + + try { + const data = JSON.parse(event.data) as Record; + + // Plex SSE has two formats: + // 1. Named events ('playing'): { PlaySessionStateNotification: { sessionKey, state, ... } } + // 2. Message events: { NotificationContainer: { type, PlaySessionStateNotification: [...] } } + + // Handle direct PlaySessionStateNotification (named 'playing' events) + if ('PlaySessionStateNotification' in data && !('NotificationContainer' in data)) { + const notification = data.PlaySessionStateNotification as PlexPlaySessionNotification; + this.handlePlaySessionNotification(notification, 'playing'); + return; + } + + // Handle wrapped NotificationContainer format (legacy/message events) + const container = (data as unknown as PlexSSENotification).NotificationContainer; + if (container?.PlaySessionStateNotification) { + const notifications = Array.isArray(container.PlaySessionStateNotification) + ? container.PlaySessionStateNotification + : [container.PlaySessionStateNotification]; + for (const notification of notifications) { + this.handlePlaySessionNotification(notification, container.type); + } + } + } catch (error) { + console.error(`[SSE] Failed to parse message from ${this.serverName}:`, error); + } + } + + /** + * Handle play session notification + * + * NOTE: Plex sends all playback events as container.type='playing'. + * The actual state (playing/paused/stopped) is in notification.state. + * See: https://www.plexopedia.com/plex-media-server/api/server/listen-events/ + */ + private handlePlaySessionNotification( + notification: PlexPlaySessionNotification, + _eventType: string + ): void { + // Use notification.state for the actual playback state + // container.type is often 'playing' for ALL playback events + switch (notification.state) { + case 'playing': + this.emit('session:playing', notification); + break; + case 'paused': + this.emit('session:paused', notification); + break; + case 'stopped': + this.emit('session:stopped', notification); + break; + case 'buffering': + // Treat buffering as playing (will resume shortly) + this.emit('session:playing', notification); + break; + } + } + + /** + * Handle connection error + */ + private handleError(error: unknown): void { + let errorMessage = 'Connection error'; + + if (error instanceof Error) { + errorMessage = error.message; + // Log full error for debugging + console.error(`[SSE] Full error on ${this.serverName}:`, error); + } else if (typeof error === 'object' && error !== null) { + // EventSource error events may have additional info + const errorObj = error as Record; + if ('message' in errorObj) { + errorMessage = String(errorObj.message); + } + if ('status' in errorObj) { + errorMessage += ` (status: ${errorObj.status})`; + } + console.error(`[SSE] Error event on ${this.serverName}:`, JSON.stringify(errorObj, null, 2)); + } + + this.lastError = error instanceof Error ? error : new Error(errorMessage); + + console.error(`[SSE] Error on ${this.serverName}:`, errorMessage); + this.emit('connection:error', this.lastError); + + // Clean up current connection + if (this.eventSource) { + this.eventSource.onopen = null; + this.eventSource.onerror = null; + // Note: addEventListener listeners are cleaned up when close() is called + this.eventSource.close(); + this.eventSource = null; + } + + // Attempt reconnection with exponential backoff + this.scheduleReconnect(); + } + + /** + * Schedule a reconnection attempt + */ + private scheduleReconnect(): void { + if (this.reconnectAttempts >= SSE_CONFIG.MAX_RETRIES) { + console.error(`[SSE] Max retries (${SSE_CONFIG.MAX_RETRIES}) reached for ${this.serverName}, falling back to polling`); + this.setState('fallback'); + return; + } + + this.setState('reconnecting'); + this.reconnectAttempts++; + + // Exponential backoff with jitter + const baseDelay = Math.min( + SSE_CONFIG.INITIAL_RETRY_DELAY_MS * Math.pow(SSE_CONFIG.RETRY_MULTIPLIER, this.reconnectAttempts - 1), + SSE_CONFIG.MAX_RETRY_DELAY_MS + ); + const jitter = Math.random() * 1000; // Add up to 1s jitter + const delay = baseDelay + jitter; + + console.log(`[SSE] Reconnecting to ${this.serverName} in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts}/${SSE_CONFIG.MAX_RETRIES})`); + + this.reconnectTimer = setTimeout(() => { + void this.connect(); + }, delay); + } + + /** + * Start heartbeat monitor + * If we don't receive any events for HEARTBEAT_TIMEOUT_MS, consider connection dead + */ + private startHeartbeatMonitor(): void { + this.resetHeartbeatMonitor(); + } + + /** + * Reset heartbeat timer (called on each message) + */ + private resetHeartbeatMonitor(): void { + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + } + + this.heartbeatTimer = setTimeout(() => { + console.warn(`[SSE] Heartbeat timeout on ${this.serverName}, reconnecting`); + this.handleError(new Error('Heartbeat timeout')); + }, SSE_CONFIG.HEARTBEAT_TIMEOUT_MS); + } + + /** + * Clear all timers + */ + private clearTimers(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + /** + * Update and emit connection state + */ + private setState(state: SSEConnectionState): void { + if (this.state !== state) { + this.state = state; + this.emit('connection:state', state); + } + } +} diff --git a/apps/server/src/services/mediaServer/plex/parser.ts b/apps/server/src/services/mediaServer/plex/parser.ts new file mode 100644 index 0000000..01d790c --- /dev/null +++ b/apps/server/src/services/mediaServer/plex/parser.ts @@ -0,0 +1,514 @@ +/** + * Plex API Response Parser + * + * Pure functions for parsing raw Plex API responses into typed objects. + * Separated from the client for testability and reuse. + */ + +import { + parseString, + parseNumber, + parseBoolean, + parseOptionalString, + parseOptionalNumber, + parseArray, + parseFirstArrayElement, +} from '../../../utils/parsing.js'; +import type { MediaSession, MediaUser, MediaLibrary, MediaWatchHistoryItem } from '../types.js'; + +// ============================================================================ +// Raw Plex API Response Types (for internal use) +// ============================================================================ + +/** Raw session metadata from Plex API */ +export interface PlexRawSession { + sessionKey?: unknown; + ratingKey?: unknown; + title?: unknown; + type?: unknown; + duration?: unknown; + viewOffset?: unknown; + grandparentTitle?: unknown; + parentTitle?: unknown; + grandparentRatingKey?: unknown; + parentIndex?: unknown; + index?: unknown; + year?: unknown; + thumb?: unknown; + grandparentThumb?: unknown; + art?: unknown; + User?: Record; + Player?: Record; + Media?: Array>; + TranscodeSession?: Record; +} + +// ============================================================================ +// Session Parsing +// ============================================================================ + +/** + * Parse Plex media type to unified type + */ +function parseMediaType(type: unknown): MediaSession['media']['type'] { + const typeStr = parseString(type).toLowerCase(); + switch (typeStr) { + case 'movie': + return 'movie'; + case 'episode': + return 'episode'; + case 'track': + return 'track'; + case 'photo': + return 'photo'; + default: + return 'unknown'; + } +} + +/** + * Parse player state from Plex to unified state + */ +function parsePlaybackState(state: unknown): MediaSession['playback']['state'] { + const stateStr = parseString(state, 'playing').toLowerCase(); + switch (stateStr) { + case 'paused': + return 'paused'; + case 'buffering': + return 'buffering'; + default: + return 'playing'; + } +} + +/** + * Calculate progress percentage from position and duration + */ +function calculateProgress(positionMs: number, durationMs: number): number { + if (durationMs <= 0) return 0; + return Math.min(100, Math.round((positionMs / durationMs) * 100)); +} + +/** + * Parse raw Plex session data into a MediaSession object + */ +export function parseSession(item: Record): MediaSession { + const player = (item.Player as Record) ?? {}; + const user = (item.User as Record) ?? {}; + const sessionInfo = (item.Session as Record) ?? {}; + const transcodeSession = item.TranscodeSession as Record | undefined; + + const durationMs = parseNumber(item.duration); + const positionMs = parseNumber(item.viewOffset); + const mediaType = parseMediaType(item.type); + + // Get bitrate and resolution from Media array (first element) + const bitrate = parseNumber(parseFirstArrayElement(item.Media, 'bitrate')); + const videoResolution = parseOptionalString(parseFirstArrayElement(item.Media, 'videoResolution')); + const videoHeight = parseOptionalNumber(parseFirstArrayElement(item.Media, 'height')); + + // Determine transcode status + const videoDecision = parseString(transcodeSession?.videoDecision, 'directplay'); + const isTranscode = videoDecision !== 'directplay' && videoDecision !== 'copy'; + + const session: MediaSession = { + sessionKey: parseString(item.sessionKey), + mediaId: parseString(item.ratingKey), + user: { + id: parseString(user.id), + username: parseString(user.title), + thumb: parseOptionalString(user.thumb), + }, + media: { + title: parseString(item.title), + type: mediaType, + durationMs, + year: parseOptionalNumber(item.year), + thumbPath: parseOptionalString(item.thumb), + }, + playback: { + state: parsePlaybackState(player.state), + positionMs, + progressPercent: calculateProgress(positionMs, durationMs), + }, + player: { + name: parseString(player.title), + deviceId: parseString(player.machineIdentifier), + product: parseOptionalString(player.product), + device: parseOptionalString(player.device), + platform: parseOptionalString(player.platform), + }, + network: { + // Prefer remote public IP for geo, fall back to local IP + ipAddress: parseString(player.remotePublicAddress) || parseString(player.address), + isLocal: parseBoolean(player.local), + }, + quality: { + bitrate, + isTranscode, + videoDecision, + videoResolution, + videoHeight, + }, + // Plex termination API requires Session.id, not sessionKey + plexSessionId: parseOptionalString(sessionInfo.id), + }; + + // Add episode-specific metadata if this is an episode + if (mediaType === 'episode') { + session.episode = { + showTitle: parseString(item.grandparentTitle), + showId: parseOptionalString(item.grandparentRatingKey), + seasonNumber: parseNumber(item.parentIndex), + episodeNumber: parseNumber(item.index), + seasonName: parseOptionalString(item.parentTitle), + showThumbPath: parseOptionalString(item.grandparentThumb), + }; + } + + return session; +} + +/** + * Parse Plex sessions API response + */ +export function parseSessionsResponse(data: unknown): MediaSession[] { + const container = data as { MediaContainer?: { Metadata?: unknown[] } }; + const metadata = container?.MediaContainer?.Metadata; + return parseArray(metadata, (item) => parseSession(item as Record)); +} + +// ============================================================================ +// User Parsing +// ============================================================================ + +/** + * Parse raw Plex user data into a MediaUser object + * Used for local server accounts from /accounts endpoint + */ +export function parseLocalUser(user: Record): MediaUser { + const userId = parseString(user.id); + return { + id: userId, + username: parseString(user.name), + email: undefined, // Local accounts don't have email + thumb: parseOptionalString(user.thumb), + // Account ID 1 is typically the owner + isAdmin: userId === '1' || parseNumber(user.id) === 1, + isDisabled: false, + }; +} + +/** + * Parse Plex.tv user data into a MediaUser object + * Used for users from plex.tv API endpoints + */ +export function parsePlexTvUser( + user: Record, + sharedLibraries?: string[] +): MediaUser { + return { + id: parseString(user.id), + username: parseString(user.username) || parseString(user.title), + email: parseOptionalString(user.email), + thumb: parseOptionalString(user.thumb), + isAdmin: parseBoolean(user.isAdmin), + isDisabled: false, + isHomeUser: parseBoolean(user.home) || parseBoolean(user.isHomeUser), + sharedLibraries: sharedLibraries ?? [], + }; +} + +/** + * Parse Plex local accounts API response + */ +export function parseUsersResponse(data: unknown): MediaUser[] { + const container = data as { MediaContainer?: { Account?: unknown[] } }; + const accounts = container?.MediaContainer?.Account; + return parseArray(accounts, (user) => parseLocalUser(user as Record)); +} + +// ============================================================================ +// Library Parsing +// ============================================================================ + +/** + * Parse raw Plex library data into a MediaLibrary object + */ +export function parseLibrary(dir: Record): MediaLibrary { + return { + id: parseString(dir.key), + name: parseString(dir.title), + type: parseString(dir.type), + agent: parseOptionalString(dir.agent), + scanner: parseOptionalString(dir.scanner), + }; +} + +/** + * Parse Plex libraries API response + */ +export function parseLibrariesResponse(data: unknown): MediaLibrary[] { + const container = data as { MediaContainer?: { Directory?: unknown[] } }; + const directories = container?.MediaContainer?.Directory; + return parseArray(directories, (dir) => parseLibrary(dir as Record)); +} + +// ============================================================================ +// Watch History Parsing +// ============================================================================ + +/** + * Parse raw Plex watch history item + */ +export function parseWatchHistoryItem(item: Record): MediaWatchHistoryItem { + const mediaType = parseMediaType(item.type); + + const historyItem: MediaWatchHistoryItem = { + mediaId: parseString(item.ratingKey), + title: parseString(item.title), + type: mediaType === 'photo' ? 'unknown' : mediaType, + // Plex returns Unix timestamp + watchedAt: parseNumber(item.lastViewedAt) || parseNumber(item.viewedAt), + userId: parseOptionalString(item.accountID), + }; + + // Add episode metadata if applicable + if (mediaType === 'episode') { + historyItem.episode = { + showTitle: parseString(item.grandparentTitle), + seasonNumber: parseOptionalNumber(item.parentIndex), + episodeNumber: parseOptionalNumber(item.index), + }; + } + + return historyItem; +} + +/** + * Parse Plex watch history API response + */ +export function parseWatchHistoryResponse(data: unknown): MediaWatchHistoryItem[] { + const container = data as { MediaContainer?: { Metadata?: unknown[] } }; + const metadata = container?.MediaContainer?.Metadata; + return parseArray(metadata, (item) => + parseWatchHistoryItem(item as Record) + ); +} + +// ============================================================================ +// Server Resource Parsing (for plex.tv API) +// ============================================================================ + +/** + * Server connection details + */ +export interface PlexServerConnection { + protocol: string; + address: string; + port: number; + uri: string; + local: boolean; +} + +/** + * Server resource from plex.tv + */ +export interface PlexServerResource { + name: string; + product: string; + productVersion: string; + platform: string; + clientIdentifier: string; + owned: boolean; + accessToken: string; + publicAddress: string; + connections: PlexServerConnection[]; +} + +/** + * Parse server connection + */ +export function parseServerConnection(conn: Record): PlexServerConnection { + return { + protocol: parseString(conn.protocol, 'http'), + address: parseString(conn.address), + port: parseNumber(conn.port, 32400), + uri: parseString(conn.uri), + local: parseBoolean(conn.local), + }; +} + +/** + * Parse server resource from plex.tv resources API + */ +export function parseServerResource( + resource: Record, + fallbackToken: string +): PlexServerResource { + const connections = parseArray( + resource.connections, + (conn) => parseServerConnection(conn as Record) + ); + + return { + name: parseString(resource.name, 'Plex Server'), + product: parseString(resource.product), + productVersion: parseString(resource.productVersion), + platform: parseString(resource.platform), + clientIdentifier: parseString(resource.clientIdentifier), + owned: parseBoolean(resource.owned), + accessToken: parseString(resource.accessToken) || fallbackToken, + publicAddress: parseString(resource.publicAddress), + connections, + }; +} + +/** + * Parse and filter plex.tv resources for owned Plex Media Servers + */ +export function parseServerResourcesResponse( + data: unknown, + fallbackToken: string +): PlexServerResource[] { + if (!Array.isArray(data)) return []; + + return data + .filter( + (r) => + (r as Record).provides === 'server' && + (r as Record).owned === true && + (r as Record).product === 'Plex Media Server' + ) + .map((r) => parseServerResource(r as Record, fallbackToken)); +} + +// ============================================================================ +// XML Parsing Helpers (for plex.tv endpoints that return XML) +// ============================================================================ + +/** + * Extract attribute value from XML string + */ +export function extractXmlAttribute(xml: string, attr: string): string { + const match = xml.match(new RegExp(`${attr}="([^"]+)"`)); + return match?.[1] ?? ''; +} + +/** + * Extract ID attribute (handles both 'id' and ' id' patterns) + */ +export function extractXmlId(xml: string): string { + const match = xml.match(/(?:^|\s)id="([^"]+)"/); + return match?.[1] ?? ''; +} + +/** + * Parse a user from XML (from /api/users endpoint) + */ +export function parseXmlUser(userXml: string): MediaUser { + return { + id: extractXmlId(userXml), + username: extractXmlAttribute(userXml, 'username') || extractXmlAttribute(userXml, 'title'), + email: extractXmlAttribute(userXml, 'email') || undefined, + thumb: extractXmlAttribute(userXml, 'thumb') || undefined, + isAdmin: false, + isHomeUser: extractXmlAttribute(userXml, 'home') === '1', + sharedLibraries: [], + }; +} + +/** + * Parse users from XML response (plex.tv /api/users) + */ +export function parseXmlUsersResponse(xml: string): MediaUser[] { + const userMatches = Array.from(xml.matchAll(/]*(?:\/>|>[\s\S]*?<\/User>)/g)); + return userMatches.map((match) => parseXmlUser(match[0])); +} + +/** + * Parse shared server info from XML (plex.tv /api/servers/{id}/shared_servers) + */ +export function parseSharedServersXml( + xml: string +): Map { + const userMap = new Map(); + const serverMatches = Array.from(xml.matchAll(/]*>[\s\S]*?<\/SharedServer>/g)); + + for (const match of serverMatches) { + const serverXml = match[0]; + const userId = extractXmlAttribute(serverXml, 'userID'); + const serverToken = extractXmlAttribute(serverXml, 'accessToken'); + + // Get shared libraries - sections with shared="1" + const sectionMatches = Array.from(serverXml.matchAll(/]*shared="1"[^>]*>/g)); + const sharedLibraries = sectionMatches + .map((sectionMatch) => extractXmlAttribute(sectionMatch[0], 'key')) + .filter((key): key is string => key !== ''); + + if (userId) { + userMap.set(userId, { serverToken, sharedLibraries }); + } + } + + return userMap; +} + +// ============================================================================ +// Server Resource Statistics Parsing +// ============================================================================ + +/** Raw statistics resource data point from Plex API */ +interface PlexRawStatisticsResource { + at?: unknown; + timespan?: unknown; + hostCpuUtilization?: unknown; + processCpuUtilization?: unknown; + hostMemoryUtilization?: unknown; + processMemoryUtilization?: unknown; +} + +/** Parsed statistics data point */ +export interface PlexStatisticsDataPoint { + at: number; + timespan: number; + hostCpuUtilization: number; + processCpuUtilization: number; + hostMemoryUtilization: number; + processMemoryUtilization: number; +} + +/** + * Parse a single statistics resource data point + */ +function parseStatisticsDataPoint(raw: PlexRawStatisticsResource): PlexStatisticsDataPoint { + return { + at: parseNumber(raw.at), + timespan: parseNumber(raw.timespan, 6), + hostCpuUtilization: parseNumber(raw.hostCpuUtilization, 0), + processCpuUtilization: parseNumber(raw.processCpuUtilization, 0), + hostMemoryUtilization: parseNumber(raw.hostMemoryUtilization, 0), + processMemoryUtilization: parseNumber(raw.processMemoryUtilization, 0), + }; +} + +/** + * Parse statistics resources response from /statistics/resources endpoint + * Returns array of data points sorted by timestamp (newest first) + */ +export function parseStatisticsResourcesResponse(data: unknown): PlexStatisticsDataPoint[] { + if (!data || typeof data !== 'object') { + return []; + } + + const container = (data as Record).MediaContainer; + if (!container || typeof container !== 'object') { + return []; + } + + const rawStats = (container as Record).StatisticsResources; + + return parseArray(rawStats, (item) => + parseStatisticsDataPoint(item as PlexRawStatisticsResource) + ).sort((a, b) => b.at - a.at); // Sort newest first +} diff --git a/apps/server/src/services/mediaServer/types.ts b/apps/server/src/services/mediaServer/types.ts new file mode 100644 index 0000000..706be1b --- /dev/null +++ b/apps/server/src/services/mediaServer/types.ts @@ -0,0 +1,285 @@ +/** + * Media Server Integration Types + * + * Common interfaces for Plex and Jellyfin media server integrations. + * Enables code reuse across different media server implementations. + */ + +import type { ServerType } from '@tracearr/shared'; + +// ============================================================================ +// Session Types +// ============================================================================ + +/** + * Unified session representation across media servers + * Contains common fields needed for session tracking and display + */ +export interface MediaSession { + /** Unique session identifier from media server */ + sessionKey: string; + + /** Media item identifier (ratingKey for Plex, itemId for Jellyfin) */ + mediaId: string; + + /** User information */ + user: { + id: string; + username: string; + thumb?: string; + }; + + /** Media metadata */ + media: { + title: string; + type: 'movie' | 'episode' | 'track' | 'photo' | 'unknown'; + /** Duration in milliseconds */ + durationMs: number; + /** Release year */ + year?: number; + /** Poster/thumbnail path */ + thumbPath?: string; + }; + + /** Episode-specific metadata (only present for episodes) */ + episode?: { + showTitle: string; + showId?: string; + seasonNumber: number; + episodeNumber: number; + /** Season name (e.g., "Season 1") */ + seasonName?: string; + /** Show poster path */ + showThumbPath?: string; + }; + + /** Playback state */ + playback: { + state: 'playing' | 'paused' | 'buffering'; + /** Current position in milliseconds */ + positionMs: number; + /** Progress percentage (0-100) */ + progressPercent: number; + }; + + /** Player/device information */ + player: { + /** Friendly device name */ + name: string; + /** Unique device identifier */ + deviceId: string; + /** Product/app name (e.g., "Plex for iOS") */ + product?: string; + /** Device type (e.g., "iPhone") */ + device?: string; + /** Platform (e.g., "iOS") */ + platform?: string; + }; + + /** Network information */ + network: { + /** Client IP address (prefer public IP for geo) */ + ipAddress: string; + /** Whether client is on local network */ + isLocal: boolean; + }; + + /** Stream quality information */ + quality: { + /** Bitrate in kbps */ + bitrate: number; + /** Whether stream is being transcoded */ + isTranscode: boolean; + /** Video decision (directplay, copy, transcode) - normalized to lowercase */ + videoDecision: string; + /** Video resolution (e.g., "4k", "1080", "720", "480", "sd") */ + videoResolution?: string; + /** Video height in pixels (for calculating resolution) */ + videoHeight?: number; + }; + + /** + * Jellyfin-specific: When the current pause started (from API). + * More accurate than tracking pause transitions via polling. + * Plex doesn't provide this field. + */ + lastPausedDate?: Date; + + /** + * Plex Session.id - required for termination API (different from sessionKey!) + * For Jellyfin/Emby, this is the same as sessionKey so can be omitted. + */ + plexSessionId?: string; +} + +// ============================================================================ +// User Types +// ============================================================================ + +/** + * Unified user representation across media servers + */ +export interface MediaUser { + /** User ID from media server */ + id: string; + /** Display name */ + username: string; + /** Email address (may be empty for local accounts) */ + email?: string; + /** Avatar/profile image URL */ + thumb?: string; + /** Whether user is an administrator */ + isAdmin: boolean; + /** Whether user account is disabled */ + isDisabled?: boolean; + /** Plex-specific: whether this is a home/managed user */ + isHomeUser?: boolean; + /** Library IDs this user has access to (empty = all libraries) */ + sharedLibraries?: string[]; + /** Last login timestamp */ + lastLoginAt?: Date; + /** Last activity timestamp */ + lastActivityAt?: Date; +} + +// ============================================================================ +// Library Types +// ============================================================================ + +/** + * Unified library representation across media servers + */ +export interface MediaLibrary { + /** Library identifier */ + id: string; + /** Library display name */ + name: string; + /** Library type (movies, shows, music, photos, etc.) */ + type: string; + /** Plex: agent identifier */ + agent?: string; + /** Plex: scanner identifier */ + scanner?: string; + /** Jellyfin: file system locations */ + locations?: string[]; +} + +// ============================================================================ +// Watch History Types +// ============================================================================ + +/** + * Unified watch history item representation + */ +export interface MediaWatchHistoryItem { + /** Media item identifier */ + mediaId: string; + /** Item title */ + title: string; + /** Media type */ + type: 'movie' | 'episode' | 'track' | 'unknown'; + /** When item was last watched (Unix timestamp or ISO string) */ + watchedAt: number | string; + /** User ID who watched (if available) */ + userId?: string; + /** Episode-specific metadata */ + episode?: { + showTitle: string; + seasonNumber?: number; + episodeNumber?: number; + }; + /** Play count (Jellyfin-specific) */ + playCount?: number; +} + +// ============================================================================ +// Media Server Client Interface +// ============================================================================ + +/** + * Configuration for creating a media server client + */ +export interface MediaServerConfig { + /** Server URL (without trailing slash) */ + url: string; + /** Authentication token (encrypted) */ + token: string; + /** Server ID (for logging and reference) */ + id?: string; + /** Server name (for logging) */ + name?: string; +} + +/** + * Common interface for media server clients + * + * Both PlexClient and JellyfinClient implement this interface, + * enabling polymorphic usage in the poller, sync, and other services. + * + * @example + * const client = createMediaServerClient(server.type, { url, token }); + * const sessions = await client.getSessions(); + * const users = await client.getUsers(); + */ +export interface IMediaServerClient { + /** The type of media server this client connects to */ + readonly serverType: ServerType; + + /** + * Get all active playback sessions + */ + getSessions(): Promise; + + /** + * Get all users with access to this server + */ + getUsers(): Promise; + + /** + * Get all libraries on this server + */ + getLibraries(): Promise; + + /** + * Test connection to the server + * @returns true if connection successful, false otherwise + */ + testConnection(): Promise; + + /** + * Terminate a playback session + * + * @param sessionId - The session ID to terminate (use terminationId from MediaSession) + * @param reason - Optional message to display to user (Plex only) + * @returns true if successful + * @throws Error if termination fails + */ + terminateSession(sessionId: string, reason?: string): Promise; +} + +/** + * Extended client interface with optional watch history support + * Not all servers support watch history in the same way + */ +export interface IMediaServerClientWithHistory extends IMediaServerClient { + /** + * Get watch history + * @param options - Optional filters for history retrieval + */ + getWatchHistory(options?: { + userId?: string; + limit?: number; + }): Promise; +} + +// ============================================================================ +// Factory Types +// ============================================================================ + +/** + * Options for creating a media server client + */ +export interface CreateClientOptions extends MediaServerConfig { + /** Server type */ + type: ServerType; +} diff --git a/apps/server/src/services/notify.ts b/apps/server/src/services/notify.ts new file mode 100644 index 0000000..81e9372 --- /dev/null +++ b/apps/server/src/services/notify.ts @@ -0,0 +1,402 @@ +/** + * Notification dispatch service + */ + +import type { ViolationWithDetails, ActiveSession, Settings, WebhookFormat } from '@tracearr/shared'; +import { NOTIFICATION_EVENTS, RULE_DISPLAY_NAMES, SEVERITY_LEVELS } from '@tracearr/shared'; + +export interface NotificationPayload { + event: string; + timestamp: string; + data: Record; +} + +interface NtfyPayload { + topic: string; + title: string; + message: string; + priority: number; + tags: string[]; +} + +interface ApprisePayload { + title: string; + body: string; + type: 'info' | 'success' | 'warning' | 'failure'; +} + +/** + * Map severity to ntfy priority (1-5 scale) + */ +function severityToNtfyPriority(severity: string): number { + const map: Record = { high: 5, warning: 4, low: 3 }; + return map[severity] ?? 3; +} + +/** + * Map severity to Apprise notification type + */ +function severityToAppriseType(severity: string): 'info' | 'success' | 'warning' | 'failure' { + const map: Record = { + high: 'failure', + warning: 'warning', + low: 'info', + }; + return map[severity] ?? 'info'; +} + +export class NotificationService { + /** + * Send violation notification + */ + async notifyViolation( + violation: ViolationWithDetails, + settings: Settings + ): Promise { + const promises: Promise[] = []; + + if (settings.discordWebhookUrl) { + promises.push(this.sendDiscord(settings.discordWebhookUrl, violation)); + } + + if (settings.customWebhookUrl) { + const payload = this.buildViolationPayload(violation); + promises.push( + this.sendFormattedWebhook(settings, payload, { violation }) + ); + } + + await Promise.allSettled(promises); + } + + /** + * Send session started notification + */ + async notifySessionStarted(session: ActiveSession, settings: Settings): Promise { + const payload: NotificationPayload = { + event: NOTIFICATION_EVENTS.STREAM_STARTED, + timestamp: new Date().toISOString(), + data: { + user: { id: session.serverUserId, username: session.user.username }, + media: { title: session.mediaTitle, type: session.mediaType }, + location: { city: session.geoCity, country: session.geoCountry }, + }, + }; + + if (settings.customWebhookUrl) { + await this.sendFormattedWebhook(settings, payload, { session, eventType: 'session_started' }); + } + } + + /** + * Send session stopped notification + */ + async notifySessionStopped(session: ActiveSession, settings: Settings): Promise { + const payload: NotificationPayload = { + event: NOTIFICATION_EVENTS.STREAM_STOPPED, + timestamp: new Date().toISOString(), + data: { + user: { id: session.serverUserId, username: session.user.username }, + media: { title: session.mediaTitle, type: session.mediaType }, + duration: session.durationMs, + }, + }; + + if (settings.customWebhookUrl) { + await this.sendFormattedWebhook(settings, payload, { session, eventType: 'session_stopped' }); + } + } + + /** + * Send server down notification + */ + async notifyServerDown(serverName: string, settings: Settings): Promise { + const payload: NotificationPayload = { + event: NOTIFICATION_EVENTS.SERVER_DOWN, + timestamp: new Date().toISOString(), + data: { serverName }, + }; + + if (settings.discordWebhookUrl) { + await this.sendDiscordMessage(settings.discordWebhookUrl, { + title: 'Server Connection Lost', + description: `Lost connection to ${serverName}`, + color: 0xff0000, + }); + } + + if (settings.customWebhookUrl) { + await this.sendFormattedWebhook(settings, payload, { serverName, eventType: 'server_down' }); + } + } + + /** + * Send server up notification + */ + async notifyServerUp(serverName: string, settings: Settings): Promise { + const payload: NotificationPayload = { + event: NOTIFICATION_EVENTS.SERVER_UP, + timestamp: new Date().toISOString(), + data: { serverName }, + }; + + if (settings.discordWebhookUrl) { + await this.sendDiscordMessage(settings.discordWebhookUrl, { + title: 'Server Back Online', + description: `${serverName} is back online`, + color: 0x2ecc71, // Green + }); + } + + if (settings.customWebhookUrl) { + await this.sendFormattedWebhook(settings, payload, { serverName, eventType: 'server_up' }); + } + } + + private buildViolationPayload(violation: ViolationWithDetails): NotificationPayload { + return { + event: NOTIFICATION_EVENTS.VIOLATION_DETECTED, + timestamp: violation.createdAt.toISOString(), + data: { + user: { id: violation.serverUserId, username: violation.user.username }, + rule: { id: violation.ruleId, type: violation.rule.type, name: violation.rule.name }, + violation: { id: violation.id, severity: violation.severity, details: violation.data }, + }, + }; + } + + private async sendDiscord(webhookUrl: string, violation: ViolationWithDetails): Promise { + const severityColors: Record = { + low: 0x3498db, + warning: 0xf39c12, + high: 0xe74c3c, + }; + + const ruleType = violation.rule.type as keyof typeof RULE_DISPLAY_NAMES; + const severity = violation.severity as keyof typeof SEVERITY_LEVELS; + + await this.sendDiscordMessage(webhookUrl, { + title: `Sharing Violation Detected`, + color: severityColors[severity] ?? 0x3498db, + fields: [ + { name: 'User', value: violation.user.username, inline: true }, + { name: 'Rule', value: RULE_DISPLAY_NAMES[ruleType], inline: true }, + { name: 'Severity', value: SEVERITY_LEVELS[severity].label, inline: true }, + { name: 'Details', value: JSON.stringify(violation.data, null, 2) }, + ], + }); + } + + private async sendDiscordMessage( + webhookUrl: string, + embed: { title: string; description?: string; color: number; fields?: unknown[] } + ): Promise { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [ + { + ...embed, + timestamp: new Date().toISOString(), + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Discord webhook failed: ${response.status}`); + } + } + + /** + * Send webhook with format-specific payload transformation + */ + private async sendFormattedWebhook( + settings: Settings, + rawPayload: NotificationPayload, + context: { + violation?: ViolationWithDetails; + session?: ActiveSession; + serverName?: string; + eventType?: string; + } + ): Promise { + if (!settings.customWebhookUrl) return; + + const format: WebhookFormat = settings.webhookFormat ?? 'json'; + let payload: unknown; + + switch (format) { + case 'ntfy': + payload = this.buildNtfyPayload(rawPayload, settings.ntfyTopic, context); + break; + case 'apprise': + payload = this.buildApprisePayload(rawPayload, context); + break; + case 'json': + default: + payload = rawPayload; + } + + await this.sendWebhook(settings.customWebhookUrl, payload); + } + + /** + * Build ntfy-formatted payload + */ + private buildNtfyPayload( + rawPayload: NotificationPayload, + topic: string | null, + context: { + violation?: ViolationWithDetails; + session?: ActiveSession; + serverName?: string; + eventType?: string; + } + ): NtfyPayload { + const { violation, session, serverName, eventType } = context; + + // Default topic if not configured + const ntfyTopic = topic || 'tracearr'; + + if (violation) { + const ruleType = violation.rule.type as keyof typeof RULE_DISPLAY_NAMES; + const severity = violation.severity as keyof typeof SEVERITY_LEVELS; + return { + topic: ntfyTopic, + title: 'Violation Detected', + message: `User ${violation.user.username} triggered ${RULE_DISPLAY_NAMES[ruleType]} (${SEVERITY_LEVELS[severity].label} severity)`, + priority: severityToNtfyPriority(violation.severity), + tags: ['warning', 'rotating_light'], + }; + } + + if (session) { + if (eventType === 'session_started') { + return { + topic: ntfyTopic, + title: 'Stream Started', + message: `${session.user.username} started watching ${session.mediaTitle}`, + priority: 3, + tags: ['arrow_forward'], + }; + } + // session_stopped + return { + topic: ntfyTopic, + title: 'Stream Stopped', + message: `${session.user.username} stopped watching ${session.mediaTitle}`, + priority: 3, + tags: ['stop_button'], + }; + } + + if (serverName) { + if (eventType === 'server_down') { + return { + topic: ntfyTopic, + title: 'Server Down', + message: `Lost connection to ${serverName}`, + priority: 5, + tags: ['rotating_light', 'x'], + }; + } + // server_up + return { + topic: ntfyTopic, + title: 'Server Online', + message: `${serverName} is back online`, + priority: 4, + tags: ['white_check_mark'], + }; + } + + // Fallback for unknown event types + return { + topic: ntfyTopic, + title: rawPayload.event, + message: JSON.stringify(rawPayload.data), + priority: 3, + tags: ['bell'], + }; + } + + /** + * Build Apprise-formatted payload + */ + private buildApprisePayload( + rawPayload: NotificationPayload, + context: { + violation?: ViolationWithDetails; + session?: ActiveSession; + serverName?: string; + eventType?: string; + } + ): ApprisePayload { + const { violation, session, serverName, eventType } = context; + + if (violation) { + const ruleType = violation.rule.type as keyof typeof RULE_DISPLAY_NAMES; + const severity = violation.severity as keyof typeof SEVERITY_LEVELS; + return { + title: 'Violation Detected', + body: `User ${violation.user.username} triggered ${RULE_DISPLAY_NAMES[ruleType]} (${SEVERITY_LEVELS[severity].label} severity)`, + type: severityToAppriseType(violation.severity), + }; + } + + if (session) { + if (eventType === 'session_started') { + return { + title: 'Stream Started', + body: `${session.user.username} started watching ${session.mediaTitle}`, + type: 'info', + }; + } + // session_stopped + return { + title: 'Stream Stopped', + body: `${session.user.username} stopped watching ${session.mediaTitle}`, + type: 'info', + }; + } + + if (serverName) { + if (eventType === 'server_down') { + return { + title: 'Server Down', + body: `Lost connection to ${serverName}`, + type: 'failure', + }; + } + // server_up + return { + title: 'Server Online', + body: `${serverName} is back online`, + type: 'success', + }; + } + + // Fallback for unknown event types + return { + title: rawPayload.event, + body: JSON.stringify(rawPayload.data), + type: 'info', + }; + } + + private async sendWebhook(webhookUrl: string, payload: unknown): Promise { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Webhook failed: ${response.status}`); + } + } +} + +export const notificationService = new NotificationService(); diff --git a/apps/server/src/services/pushEncryption.ts b/apps/server/src/services/pushEncryption.ts new file mode 100644 index 0000000..6d0cb5d --- /dev/null +++ b/apps/server/src/services/pushEncryption.ts @@ -0,0 +1,129 @@ +/** + * Push Notification Payload Encryption Service + * + * Encrypts push notification payloads using AES-256-GCM for secure + * transmission to mobile devices. Each device has a unique secret + * that's used for key derivation, ensuring only that device can + * decrypt the payload. + * + * Security properties: + * - AES-256-GCM provides confidentiality and integrity + * - PBKDF2 key derivation with 100,000 iterations + * - Per-device secrets ensure isolation + * - Random IVs for each message prevent replay attacks + */ + +import { + createCipheriv, + randomBytes, + pbkdf2Sync, +} from 'node:crypto'; +import type { EncryptedPushPayload } from '@tracearr/shared'; + +// AES-256-GCM parameters (must match mobile client) +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 12; // 96 bits (recommended for GCM) +const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum) +const _AUTH_TAG_LENGTH = 16; // 128 bits +const PBKDF2_ITERATIONS = 100000; + +/** + * Derive encryption key using PBKDF2 + * Uses the same parameters as the mobile client for compatibility + */ +function deriveKey(deviceSecret: string, salt: Buffer): Buffer { + return pbkdf2Sync(deviceSecret, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); +} + +/** + * Encrypt a push notification payload for a specific device + * + * @param payload - The notification payload to encrypt + * @param deviceSecret - The device's unique secret (stored in mobile_sessions) + * @returns Encrypted payload in EncryptedPushPayload format + */ +export function encryptPushPayload( + payload: Record, + deviceSecret: string +): EncryptedPushPayload { + // Generate random IV and salt separately (NIST: salt should be at least 128 bits) + const iv = randomBytes(IV_LENGTH); + const salt = randomBytes(SALT_LENGTH); + + // Derive key using proper random salt + const key = deriveKey(deviceSecret, salt); + + // Create cipher + const cipher = createCipheriv(ALGORITHM, key, iv); + + // Encrypt payload + const plaintext = Buffer.from(JSON.stringify(payload), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + + // Get authentication tag + const authTag = cipher.getAuthTag(); + + return { + v: 1, + iv: iv.toString('base64'), + salt: salt.toString('base64'), + ct: encrypted.toString('base64'), + tag: authTag.toString('base64'), + }; +} + +/** + * Check if encryption should be enabled for a push notification + * + * @param deviceSecret - The device's secret (if available) + * @returns true if encryption should be used + */ +export function shouldEncryptPush(deviceSecret: string | null): boolean { + return deviceSecret !== null && deviceSecret.length > 0; +} + +/** + * Push Encryption Service + * Provides encryption capabilities for push notification payloads + */ +export class PushEncryptionService { + /** + * Encrypt payload if device has a secret, otherwise return unencrypted + * + * @param payload - Original notification payload + * @param deviceSecret - Device's encryption secret (null = no encryption) + * @returns Encrypted payload or original payload + * @throws Error if encryption is required but fails + */ + encryptIfEnabled( + payload: Record, + deviceSecret: string | null + ): Record | EncryptedPushPayload { + if (!shouldEncryptPush(deviceSecret)) { + // No encryption configured - return unencrypted (expected for devices without secrets) + return payload; + } + + // Encryption is required - do not silently fallback to unencrypted on failure + return encryptPushPayload(payload, deviceSecret!); + } + + /** + * Encrypt payload for a device (throws if encryption fails) + * + * @param payload - Original notification payload + * @param deviceSecret - Device's encryption secret + * @returns Encrypted payload + * @throws Error if encryption fails + */ + encrypt( + payload: Record, + deviceSecret: string + ): EncryptedPushPayload { + return encryptPushPayload(payload, deviceSecret); + } +} + +// Export singleton instance +export const pushEncryptionService = new PushEncryptionService(); diff --git a/apps/server/src/services/pushNotification.ts b/apps/server/src/services/pushNotification.ts new file mode 100644 index 0000000..0313955 --- /dev/null +++ b/apps/server/src/services/pushNotification.ts @@ -0,0 +1,845 @@ +/** + * Expo Push Notification Service + * + * Handles sending push notifications to mobile devices via Expo's push service. + * Supports chunking for bulk sends, receipt verification, token cleanup, + * and per-device notification preferences. + */ + +import { + Expo, + type ExpoPushMessage, + type ExpoPushTicket, +} from 'expo-server-sdk'; +import { eq, isNotNull } from 'drizzle-orm'; +import type { ViolationWithDetails, ActiveSession } from '@tracearr/shared'; +import { RULE_DISPLAY_NAMES, SEVERITY_LEVELS, getSeverityPriority } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { mobileSessions, notificationPreferences } from '../db/schema.js'; +import { getPushRateLimiter } from './pushRateLimiter.js'; +import { quietHoursService, type NotificationSeverity } from './quietHours.js'; +import { pushEncryptionService } from './pushEncryption.js'; +import { getNetworkSettings } from '../routes/settings.js'; + +// Initialize Expo SDK +const expo = new Expo(); + +// Store receipt IDs for later verification (token -> receiptId) +const pendingReceipts = new Map(); + +// Invalid tokens that should be removed +const tokensToRemove = new Set(); + +/** + * Format media title for notifications + * Movies: "Movie Title" + * TV Shows: "Show Name - S01E05 Episode Title" or "Show Name - S01E05" + */ +function formatMediaTitle(session: ActiveSession): string { + const { mediaType, mediaTitle, grandparentTitle, seasonNumber, episodeNumber } = session; + + // For episodes, format as "Show - S01E05 Episode" + if (mediaType === 'episode' && grandparentTitle) { + const seasonStr = seasonNumber != null ? String(seasonNumber).padStart(2, '0') : '00'; + const episodeStr = episodeNumber != null ? String(episodeNumber).padStart(2, '0') : '00'; + const episodeCode = `S${seasonStr}E${episodeStr}`; + + // If episode title is different from show title, include it + if (mediaTitle && mediaTitle !== grandparentTitle) { + return `${grandparentTitle} - ${episodeCode} ${mediaTitle}`; + } + return `${grandparentTitle} - ${episodeCode}`; + } + + // For movies and other media, just use the title + return mediaTitle; +} + +/** + * Build full poster URL for rich notifications + * Returns null if externalUrl is not configured or thumbPath is missing + */ +async function _buildPosterUrl(session: ActiveSession): Promise { + const { thumbPath, serverId } = session; + + // Need both thumbPath and serverId + if (!thumbPath || !serverId) { + return null; + } + + // Get external URL from settings + const networkSettings = await getNetworkSettings(); + const { externalUrl } = networkSettings; + + // External URL must be configured for rich notifications + if (!externalUrl) { + return null; + } + + // Build the full URL using image proxy + // Use smaller dimensions for notification thumbnails + const width = 150; + const height = 225; + const encodedPath = encodeURIComponent(thumbPath); + + return `${externalUrl}/api/v1/images/proxy?server=${serverId}&url=${encodedPath}&width=${width}&height=${height}&fallback=poster`; +} + +/** + * Session with preferences for filtering + */ +interface SessionWithPrefs { + expoPushToken: string; + mobileSessionId: string; + deviceSecret: string | null; + pushEnabled: boolean; + onViolationDetected: boolean; + onStreamStarted: boolean; + onStreamStopped: boolean; + onConcurrentStreams: boolean; + onNewDevice: boolean; + onTrustScoreChanged: boolean; + onServerDown: boolean; + onServerUp: boolean; + violationMinSeverity: number; + violationRuleTypes: string[] | null; + // Rate limiting + maxPerMinute: number; + maxPerHour: number; + // Quiet hours + quietHoursEnabled: boolean; + quietHoursStart: string | null; + quietHoursEnd: string | null; + quietHoursTimezone: string; + quietHoursOverrideCritical: boolean; +} + +/** + * Build push message for a device + * Encrypts the data payload if deviceSecret is provided + * Supports rich notifications with subtitle and image + */ +function buildPushMessage( + token: string, + deviceSecret: string | null, + notification: { + title: string; + subtitle?: string; // iOS subtitle, shown below title + body: string; + data?: Record; + priority?: 'default' | 'high'; + channelId?: string; + badge?: number; + sound?: 'default' | null; + imageUrl?: string | null; // Rich notification image URL (must be HTTPS) + } +): ExpoPushMessage { + // Encrypt data payload if device has a secret + const data = notification.data + ? (pushEncryptionService.encryptIfEnabled(notification.data, deviceSecret) as Record< + string, + unknown + >) + : undefined; + + const message: ExpoPushMessage = { + to: token, + title: notification.title, + body: notification.body, + data, + priority: notification.priority ?? 'default', + channelId: notification.channelId, + badge: notification.badge, + sound: notification.sound === undefined ? 'default' : notification.sound, + }; + + // Add subtitle for iOS (Android will show it as part of body) + if (notification.subtitle) { + message.subtitle = notification.subtitle; + } + + // Add rich notification image if URL is HTTPS + if (notification.imageUrl?.startsWith('https://')) { + // Note: 'richContent' property holds the image URL in Expo SDK + // Using type assertion since expo-server-sdk types may not include newer properties + (message as ExpoPushMessage & { richContent?: { url: string } }).richContent = { + url: notification.imageUrl, + }; + } + + return message; +} + +/** + * Get all valid Expo push tokens with their preferences + */ +async function getSessionsWithPreferences(): Promise { + // Query sessions with their preferences (left join to handle missing prefs) + const results = await db + .select({ + expoPushToken: mobileSessions.expoPushToken, + mobileSessionId: mobileSessions.id, + deviceSecret: mobileSessions.deviceSecret, + pushEnabled: notificationPreferences.pushEnabled, + onViolationDetected: notificationPreferences.onViolationDetected, + onStreamStarted: notificationPreferences.onStreamStarted, + onStreamStopped: notificationPreferences.onStreamStopped, + onConcurrentStreams: notificationPreferences.onConcurrentStreams, + onNewDevice: notificationPreferences.onNewDevice, + onTrustScoreChanged: notificationPreferences.onTrustScoreChanged, + onServerDown: notificationPreferences.onServerDown, + onServerUp: notificationPreferences.onServerUp, + violationMinSeverity: notificationPreferences.violationMinSeverity, + violationRuleTypes: notificationPreferences.violationRuleTypes, + maxPerMinute: notificationPreferences.maxPerMinute, + maxPerHour: notificationPreferences.maxPerHour, + quietHoursEnabled: notificationPreferences.quietHoursEnabled, + quietHoursStart: notificationPreferences.quietHoursStart, + quietHoursEnd: notificationPreferences.quietHoursEnd, + quietHoursTimezone: notificationPreferences.quietHoursTimezone, + quietHoursOverrideCritical: notificationPreferences.quietHoursOverrideCritical, + }) + .from(mobileSessions) + .leftJoin( + notificationPreferences, + eq(mobileSessions.id, notificationPreferences.mobileSessionId) + ) + .where(isNotNull(mobileSessions.expoPushToken)); + + return results + .filter((s): s is SessionWithPrefs => { + if (!s.expoPushToken) return false; + if (tokensToRemove.has(s.expoPushToken)) return false; + if (!Expo.isExpoPushToken(s.expoPushToken)) return false; + return true; + }) + .map((s) => ({ + expoPushToken: s.expoPushToken, + mobileSessionId: s.mobileSessionId, + deviceSecret: s.deviceSecret ?? null, + // Use defaults if no preferences exist + pushEnabled: s.pushEnabled ?? true, + onViolationDetected: s.onViolationDetected ?? true, + onStreamStarted: s.onStreamStarted ?? false, + onStreamStopped: s.onStreamStopped ?? false, + onConcurrentStreams: s.onConcurrentStreams ?? true, + onNewDevice: s.onNewDevice ?? true, + onTrustScoreChanged: s.onTrustScoreChanged ?? false, + onServerDown: s.onServerDown ?? true, + onServerUp: s.onServerUp ?? true, + violationMinSeverity: s.violationMinSeverity ?? 1, + violationRuleTypes: s.violationRuleTypes ?? [], + maxPerMinute: s.maxPerMinute ?? 10, + maxPerHour: s.maxPerHour ?? 60, + quietHoursEnabled: s.quietHoursEnabled ?? false, + quietHoursStart: s.quietHoursStart ?? null, + quietHoursEnd: s.quietHoursEnd ?? null, + quietHoursTimezone: s.quietHoursTimezone ?? 'UTC', + quietHoursOverrideCritical: s.quietHoursOverrideCritical ?? true, + })); +} + +/** + * Send push notifications in chunks (Expo limit: 100 per request) + */ +async function sendPushNotifications(messages: ExpoPushMessage[]): Promise { + if (messages.length === 0) return; + + const chunks = expo.chunkPushNotifications(messages); + const tickets: { token: string; ticket: ExpoPushTicket }[] = []; + + for (const chunk of chunks) { + try { + const ticketChunk = await expo.sendPushNotificationsAsync(chunk); + + // Store token -> ticket mapping for receipt handling + chunk.forEach((msg: ExpoPushMessage, idx: number) => { + const ticket = ticketChunk[idx]; + if (ticket) { + tickets.push({ token: msg.to as string, ticket }); + + // Store receipt ID for later verification + if ('id' in ticket) { + pendingReceipts.set(ticket.id, msg.to as string); + } + + // Handle immediate errors + if (ticket.status === 'error') { + handlePushError(msg.to as string, ticket.message, ticket.details); + } + } + }); + } catch (error) { + console.error('[Push] Error sending chunk:', error); + } + } + + console.log(`[Push] Sent ${messages.length} notifications, ${tickets.length} tickets`); +} + +/** + * Handle push notification error + */ +function handlePushError( + token: string, + message?: string, + details?: { error?: string } +): void { + console.error(`[Push] Error for token ${token.slice(0, 20)}...: ${message}`); + + // Mark invalid tokens for removal + if (details?.error === 'DeviceNotRegistered') { + console.log(`[Push] Marking token for removal: ${token.slice(0, 20)}...`); + tokensToRemove.add(token); + } +} + +/** + * Process push receipts to verify delivery and clean up invalid tokens + * Should be called periodically (e.g., every 15 minutes) + */ +export async function processPushReceipts(): Promise { + if (pendingReceipts.size === 0) return; + + const receiptIds = Array.from(pendingReceipts.keys()); + const receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds); + + for (const chunk of receiptIdChunks) { + try { + const receipts = await expo.getPushNotificationReceiptsAsync(chunk); + + for (const [receiptId, receipt] of Object.entries(receipts)) { + const token = pendingReceipts.get(receiptId); + + if (receipt.status === 'error') { + console.error(`[Push] Receipt error: ${receipt.message}`); + + if (token && receipt.details?.error === 'DeviceNotRegistered') { + tokensToRemove.add(token); + } + } + + pendingReceipts.delete(receiptId); + } + } catch (error) { + console.error('[Push] Error fetching receipts:', error); + } + } + + // Clean up invalid tokens from database + await cleanupInvalidTokens(); +} + +/** + * Remove invalid tokens from the database + */ +async function cleanupInvalidTokens(): Promise { + if (tokensToRemove.size === 0) return; + + const tokensArray = Array.from(tokensToRemove); + console.log(`[Push] Cleaning up ${tokensArray.length} invalid tokens`); + + for (const token of tokensArray) { + try { + await db + .update(mobileSessions) + .set({ expoPushToken: null }) + .where(eq(mobileSessions.expoPushToken, token)); + + tokensToRemove.delete(token); + } catch (error) { + console.error(`[Push] Error removing token: ${error}`); + } + } +} + +/** + * Apply quiet hours filtering to a list of sessions + * Returns only sessions that are not in quiet hours (or can bypass) + */ +function applyQuietHours( + sessions: SessionWithPrefs[], + severity: NotificationSeverity, + context: string +): SessionWithPrefs[] { + return sessions.filter((session) => { + const shouldSend = quietHoursService.shouldSend( + { + quietHoursEnabled: session.quietHoursEnabled, + quietHoursStart: session.quietHoursStart, + quietHoursEnd: session.quietHoursEnd, + quietHoursTimezone: session.quietHoursTimezone, + quietHoursOverrideCritical: session.quietHoursOverrideCritical, + }, + severity + ); + + if (!shouldSend) { + console.log( + `[Push] Quiet hours active for session ${session.mobileSessionId.slice(0, 8)}... ` + + `for ${context} (severity: ${severity})` + ); + } + + return shouldSend; + }); +} + +/** + * Apply quiet hours filtering for event-based notifications (non-severity) + */ +function applyQuietHoursEvent( + sessions: SessionWithPrefs[], + eventType: 'session_started' | 'session_stopped' | 'server_down' | 'server_up', + context: string +): SessionWithPrefs[] { + return sessions.filter((session) => { + const shouldSend = quietHoursService.shouldSendEvent( + { + quietHoursEnabled: session.quietHoursEnabled, + quietHoursStart: session.quietHoursStart, + quietHoursEnd: session.quietHoursEnd, + quietHoursTimezone: session.quietHoursTimezone, + quietHoursOverrideCritical: session.quietHoursOverrideCritical, + }, + eventType + ); + + if (!shouldSend) { + console.log( + `[Push] Quiet hours active for session ${session.mobileSessionId.slice(0, 8)}... ` + + `for ${context}` + ); + } + + return shouldSend; + }); +} + +/** + * Apply rate limiting to a list of sessions + * Returns only sessions that pass the rate limit check + */ +async function applyRateLimiting( + sessions: SessionWithPrefs[], + context: string +): Promise { + const rateLimiter = getPushRateLimiter(); + if (!rateLimiter) { + return sessions; // No rate limiter, allow all + } + + const allowed: SessionWithPrefs[] = []; + + for (const session of sessions) { + const result = await rateLimiter.checkAndRecord(session.mobileSessionId, { + maxPerMinute: session.maxPerMinute, + maxPerHour: session.maxPerHour, + }); + + if (result.allowed) { + allowed.push(session); + } else { + console.log( + `[Push] Rate limited session ${session.mobileSessionId.slice(0, 8)}... ` + + `for ${context} (${result.exceededLimit} limit exceeded)` + ); + } + } + + return allowed; +} + +// ============================================================================ +// Push Notification Service +// ============================================================================ + +export class PushNotificationService { + /** + * Send violation notification to devices that have enabled violation alerts + */ + async notifyViolation(violation: ViolationWithDetails): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + const ruleType = violation.rule.type as keyof typeof RULE_DISPLAY_NAMES; + const severity = violation.severity as keyof typeof SEVERITY_LEVELS; + const severityNum = getSeverityPriority(severity); + + // Filter sessions based on preferences + const eligibleSessions = sessions.filter((s) => { + // Check master toggle + if (!s.pushEnabled) return false; + + // Check event toggle + if (!s.onViolationDetected) return false; + + // Check minimum severity + if (severityNum < s.violationMinSeverity) return false; + + // Check rule type filter (empty array = all types) + if (s.violationRuleTypes && s.violationRuleTypes.length > 0) { + if (!s.violationRuleTypes.includes(violation.rule.type)) return false; + } + + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for violation notification`); + return; + } + + // Apply rate limiting + const rateLimitedSessions = await applyRateLimiting(eligibleSessions, 'violation'); + if (rateLimitedSessions.length === 0) { + console.log(`[Push] All sessions rate limited for violation notification`); + return; + } + + // Apply quiet hours filtering (violations use severity for bypass) + const activeSessions = applyQuietHours(rateLimitedSessions, severity as NotificationSeverity, 'violation'); + if (activeSessions.length === 0) { + console.log(`[Push] All sessions in quiet hours for violation notification`); + return; + } + + // Get server name for notification title + const serverName = violation.server?.name || 'Media Server'; + + const messages = activeSessions.map((session) => + buildPushMessage(session.expoPushToken, session.deviceSecret, { + title: serverName, + subtitle: `${SEVERITY_LEVELS[severity].label} Violation`, + body: `${violation.user.username}: ${RULE_DISPLAY_NAMES[ruleType]}`, + data: { + type: 'violation_detected', + violationId: violation.id, + userId: violation.serverUserId, + ruleType: violation.rule.type, + severity: violation.severity, + serverId: violation.server?.id, + }, + priority: severity === 'high' ? 'high' : 'default', + channelId: 'violations', + badge: 1, + sound: severity === 'high' ? 'default' : undefined, + }) + ); + + await sendPushNotifications(messages); + } + + /** + * Send session started notification to devices that have enabled stream alerts + */ + async notifySessionStarted(session: ActiveSession): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + // Filter sessions based on preferences + const eligibleSessions = sessions.filter((s) => { + if (!s.pushEnabled) return false; + if (!s.onStreamStarted) return false; + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for stream started notification`); + return; + } + + // Apply rate limiting + const rateLimitedSessions = await applyRateLimiting(eligibleSessions, 'session_started'); + if (rateLimitedSessions.length === 0) { + console.log(`[Push] All sessions rate limited for stream started notification`); + return; + } + + // Apply quiet hours filtering + const activeSessions = applyQuietHoursEvent( + rateLimitedSessions, + 'session_started', + 'session_started' + ); + if (activeSessions.length === 0) { + console.log(`[Push] All sessions in quiet hours for stream started notification`); + return; + } + + // Get server name for notification title + const serverName = session.server?.name || 'Media Server'; + const formattedTitle = formatMediaTitle(session); + + const messages = activeSessions.map((s) => + buildPushMessage(s.expoPushToken, s.deviceSecret, { + title: serverName, + subtitle: 'Now Playing', + body: `${session.user.username}: ${formattedTitle}`, + data: { + type: 'stream_started', + sessionId: session.id, + userId: session.serverUserId, + mediaTitle: session.mediaTitle, + mediaType: session.mediaType, + serverId: session.server.id, + }, + priority: 'default', + channelId: 'sessions', + }) + ); + + await sendPushNotifications(messages); + } + + /** + * Send session stopped notification to devices that have enabled stream alerts + */ + async notifySessionStopped(session: ActiveSession): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + // Filter sessions based on preferences + const eligibleSessions = sessions.filter((s) => { + if (!s.pushEnabled) return false; + if (!s.onStreamStopped) return false; + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for stream stopped notification`); + return; + } + + // Apply rate limiting + const rateLimitedSessions = await applyRateLimiting(eligibleSessions, 'session_stopped'); + if (rateLimitedSessions.length === 0) { + console.log(`[Push] All sessions rate limited for stream stopped notification`); + return; + } + + // Apply quiet hours filtering + const activeSessions = applyQuietHoursEvent( + rateLimitedSessions, + 'session_stopped', + 'session_stopped' + ); + if (activeSessions.length === 0) { + console.log(`[Push] All sessions in quiet hours for stream stopped notification`); + return; + } + + // Get server name for notification title + const serverName = session.server?.name || 'Media Server'; + const formattedTitle = formatMediaTitle(session); + + // Format duration + const durationMins = Math.round((session.durationMs || 0) / 60000); + const durationStr = + durationMins >= 60 + ? `${Math.floor(durationMins / 60)}h ${durationMins % 60}m` + : `${durationMins}m`; + + const messages = activeSessions.map((s) => + buildPushMessage(s.expoPushToken, s.deviceSecret, { + title: serverName, + subtitle: 'Stream Ended', + body: `${session.user.username}: ${formattedTitle} (${durationStr})`, + data: { + type: 'stream_stopped', + sessionId: session.id, + userId: session.serverUserId, + mediaTitle: session.mediaTitle, + durationMs: session.durationMs, + serverId: session.server.id, + }, + priority: 'default', + channelId: 'sessions', + }) + ); + + await sendPushNotifications(messages); + } + + /** + * Send server down notification to devices that have enabled server alerts + */ + async notifyServerDown(serverName: string, serverId: string): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + // Filter sessions based on preferences + const eligibleSessions = sessions.filter((s) => { + if (!s.pushEnabled) return false; + if (!s.onServerDown) return false; + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for server down notification`); + return; + } + + // Apply rate limiting + const rateLimitedSessions = await applyRateLimiting(eligibleSessions, 'server_down'); + if (rateLimitedSessions.length === 0) { + console.log(`[Push] All sessions rate limited for server down notification`); + return; + } + + // Apply quiet hours filtering (server_down is treated as critical) + const activeSessions = applyQuietHoursEvent( + rateLimitedSessions, + 'server_down', + 'server_down' + ); + if (activeSessions.length === 0) { + console.log(`[Push] All sessions in quiet hours for server down notification`); + return; + } + + const messages = activeSessions.map((s) => + buildPushMessage(s.expoPushToken, s.deviceSecret, { + title: serverName, + subtitle: 'Server Alert', + body: 'Connection lost', + data: { + type: 'server_down', + serverName, + serverId, + }, + priority: 'high', + channelId: 'alerts', + sound: 'default', + }) + ); + + await sendPushNotifications(messages); + } + + /** + * Send silent push notification for background data sync + * These notifications don't show any UI, just trigger the app's background task + */ + async sendSilentNotification( + data: Record, + skipPreferences = false + ): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + // For silent notifications, we only filter by pushEnabled (skip all other prefs) + const eligibleSessions = skipPreferences + ? sessions.filter((s) => s.pushEnabled) + : sessions.filter((s) => { + if (!s.pushEnabled) return false; + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for silent notification`); + return; + } + + // Note: Silent notifications typically skip rate limiting and quiet hours + // as they're for background data sync, not user-facing alerts + + const messages: ExpoPushMessage[] = eligibleSessions.map((session) => { + // Encrypt data payload if device has a secret + const encryptedData = pushEncryptionService.encryptIfEnabled( + { ...data, type: 'data_sync' }, + session.deviceSecret + ) as Record; + + return { + to: session.expoPushToken, + data: encryptedData, + // Silent notification settings + _contentAvailable: true, // iOS background fetch flag + priority: 'normal', // Don't wake device aggressively + // No sound, title, or body = silent + }; + }); + + await sendPushNotifications(messages); + console.log(`[Push] Sent ${messages.length} silent data_sync notifications`); + } + + /** + * Trigger a data sync push for dashboard stats refresh + */ + async triggerStatsSync(): Promise { + await this.sendSilentNotification({ + syncType: 'stats', + timestamp: Date.now(), + }); + } + + /** + * Trigger a data sync push for sessions refresh + */ + async triggerSessionsSync(): Promise { + await this.sendSilentNotification({ + syncType: 'sessions', + timestamp: Date.now(), + }); + } + + /** + * Send server up notification to devices that have enabled server alerts + */ + async notifyServerUp(serverName: string, serverId: string): Promise { + const sessions = await getSessionsWithPreferences(); + if (sessions.length === 0) return; + + // Filter sessions based on preferences + const eligibleSessions = sessions.filter((s) => { + if (!s.pushEnabled) return false; + if (!s.onServerUp) return false; + return true; + }); + + if (eligibleSessions.length === 0) { + console.log(`[Push] No eligible sessions for server up notification`); + return; + } + + // Apply rate limiting + const rateLimitedSessions = await applyRateLimiting(eligibleSessions, 'server_up'); + if (rateLimitedSessions.length === 0) { + console.log(`[Push] All sessions rate limited for server up notification`); + return; + } + + // Apply quiet hours filtering + const activeSessions = applyQuietHoursEvent( + rateLimitedSessions, + 'server_up', + 'server_up' + ); + if (activeSessions.length === 0) { + console.log(`[Push] All sessions in quiet hours for server up notification`); + return; + } + + const messages = activeSessions.map((s) => + buildPushMessage(s.expoPushToken, s.deviceSecret, { + title: serverName, + subtitle: 'Server Alert', + body: 'Back online', + data: { + type: 'server_up', + serverName, + serverId, + }, + priority: 'default', + channelId: 'alerts', + }) + ); + + await sendPushNotifications(messages); + } +} + +// Export singleton instance +export const pushNotificationService = new PushNotificationService(); diff --git a/apps/server/src/services/pushRateLimiter.ts b/apps/server/src/services/pushRateLimiter.ts new file mode 100644 index 0000000..5b07c7b --- /dev/null +++ b/apps/server/src/services/pushRateLimiter.ts @@ -0,0 +1,205 @@ +/** + * Redis-based Rate Limiter for Push Notifications + * + * Uses sliding window counters in Redis to enforce per-minute and per-hour + * limits on push notifications for each mobile session. This prevents + * notification spam and respects user preferences. + */ + +import type { Redis } from 'ioredis'; +import { REDIS_KEYS } from '@tracearr/shared'; + +/** + * Result of a rate limit check + */ +export interface RateLimitResult { + /** Whether the notification is allowed */ + allowed: boolean; + /** Remaining notifications in the minute window */ + remainingMinute: number; + /** Remaining notifications in the hour window */ + remainingHour: number; + /** Seconds until the minute window resets */ + resetMinuteIn: number; + /** Seconds until the hour window resets */ + resetHourIn: number; + /** Which limit was exceeded (if any) */ + exceededLimit?: 'minute' | 'hour'; +} + +/** + * Rate limit preferences for a session + */ +export interface RateLimitPrefs { + maxPerMinute: number; + maxPerHour: number; +} + +/** + * Lua script for atomic rate limit check-and-increment + * + * KEYS[1] = minute key + * KEYS[2] = hour key + * ARGV[1] = max per minute + * ARGV[2] = max per hour + * + * Returns: [allowed (0/1), minuteCount, hourCount, minuteTTL, hourTTL, exceededLimit (0=none, 1=minute, 2=hour)] + */ +const RATE_LIMIT_SCRIPT = ` +local minuteKey = KEYS[1] +local hourKey = KEYS[2] +local maxPerMinute = tonumber(ARGV[1]) +local maxPerHour = tonumber(ARGV[2]) + +-- Get current counts +local minuteCount = tonumber(redis.call('GET', minuteKey) or '0') +local hourCount = tonumber(redis.call('GET', hourKey) or '0') + +-- Get TTLs +local minuteTTL = redis.call('TTL', minuteKey) +local hourTTL = redis.call('TTL', hourKey) + +-- Check minute limit first +if minuteCount >= maxPerMinute then + return {0, minuteCount, hourCount, minuteTTL, hourTTL, 1} +end + +-- Check hour limit +if hourCount >= maxPerHour then + return {0, minuteCount, hourCount, minuteTTL, hourTTL, 2} +end + +-- Increment minute counter atomically +local newMinuteCount = redis.call('INCR', minuteKey) +if minuteTTL < 0 then + redis.call('EXPIRE', minuteKey, 60) + minuteTTL = 60 +end + +-- Increment hour counter atomically +local newHourCount = redis.call('INCR', hourKey) +if hourTTL < 0 then + redis.call('EXPIRE', hourKey, 3600) + hourTTL = 3600 +end + +return {1, newMinuteCount, newHourCount, minuteTTL, hourTTL, 0} +`; + +/** + * Push notification rate limiter using Redis sliding windows + */ +export class PushRateLimiter { + private redis: Redis; + + constructor(redis: Redis) { + this.redis = redis; + } + + /** + * Check if a notification is allowed and record it if so + * + * Uses a Lua script for atomic check-and-increment to prevent race conditions. + * Returns the result including remaining limits and reset times. + */ + async checkAndRecord( + mobileSessionId: string, + prefs: RateLimitPrefs + ): Promise { + const minuteKey = REDIS_KEYS.PUSH_RATE_MINUTE(mobileSessionId); + const hourKey = REDIS_KEYS.PUSH_RATE_HOUR(mobileSessionId); + + // Execute atomic Lua script + const result = (await this.redis.eval( + RATE_LIMIT_SCRIPT, + 2, + minuteKey, + hourKey, + prefs.maxPerMinute.toString(), + prefs.maxPerHour.toString() + )) as [number, number, number, number, number, number]; + + const [allowed, minuteCount, hourCount, minuteTTL, hourTTL, exceededLimit] = result; + + // Calculate remaining + const remainingMinute = Math.max(0, prefs.maxPerMinute - minuteCount); + const remainingHour = Math.max(0, prefs.maxPerHour - hourCount); + + // Calculate reset times (TTL returns -2 if key doesn't exist, -1 if no expiry) + const resetMinuteIn = minuteTTL > 0 ? minuteTTL : 60; + const resetHourIn = hourTTL > 0 ? hourTTL : 3600; + + if (!allowed) { + return { + allowed: false, + remainingMinute: exceededLimit === 1 ? 0 : remainingMinute, + remainingHour: exceededLimit === 2 ? 0 : remainingHour, + resetMinuteIn, + resetHourIn, + exceededLimit: exceededLimit === 1 ? 'minute' : 'hour', + }; + } + + return { + allowed: true, + remainingMinute, + remainingHour, + resetMinuteIn, + resetHourIn, + }; + } + + /** + * Check rate limit status without recording (for UI display) + */ + async getStatus( + mobileSessionId: string, + prefs: RateLimitPrefs + ): Promise> { + const minuteKey = REDIS_KEYS.PUSH_RATE_MINUTE(mobileSessionId); + const hourKey = REDIS_KEYS.PUSH_RATE_HOUR(mobileSessionId); + + const [minuteCount, hourCount, minuteTTL, hourTTL] = await Promise.all([ + this.redis.get(minuteKey).then((v) => parseInt(v ?? '0', 10)), + this.redis.get(hourKey).then((v) => parseInt(v ?? '0', 10)), + this.redis.ttl(minuteKey), + this.redis.ttl(hourKey), + ]); + + return { + remainingMinute: Math.max(0, prefs.maxPerMinute - minuteCount), + remainingHour: Math.max(0, prefs.maxPerHour - hourCount), + resetMinuteIn: minuteTTL > 0 ? minuteTTL : 60, + resetHourIn: hourTTL > 0 ? hourTTL : 3600, + }; + } + + /** + * Reset rate limits for a session (for testing/admin) + */ + async reset(mobileSessionId: string): Promise { + const minuteKey = REDIS_KEYS.PUSH_RATE_MINUTE(mobileSessionId); + const hourKey = REDIS_KEYS.PUSH_RATE_HOUR(mobileSessionId); + + await this.redis.del(minuteKey, hourKey); + } +} + +// Module-level instance storage +let rateLimiterInstance: PushRateLimiter | null = null; + +/** + * Initialize the push rate limiter with a Redis connection + */ +export function initPushRateLimiter(redis: Redis): PushRateLimiter { + rateLimiterInstance = new PushRateLimiter(redis); + return rateLimiterInstance; +} + +/** + * Get the global push rate limiter instance + * Returns null if not initialized + */ +export function getPushRateLimiter(): PushRateLimiter | null { + return rateLimiterInstance; +} diff --git a/apps/server/src/services/quietHours.ts b/apps/server/src/services/quietHours.ts new file mode 100644 index 0000000..c89e068 --- /dev/null +++ b/apps/server/src/services/quietHours.ts @@ -0,0 +1,130 @@ +/** + * Quiet Hours Service + * + * Suppresses non-critical notifications during user-configured quiet hours. + * Supports timezone-aware time comparison and overnight quiet hour ranges. + */ + +/** + * Quiet hours preferences from notification settings + */ +export interface QuietHoursPrefs { + quietHoursEnabled: boolean; + quietHoursStart: string | null; // "23:00" + quietHoursEnd: string | null; // "07:00" + quietHoursTimezone: string; + quietHoursOverrideCritical: boolean; +} + +/** + * Severity levels that can bypass quiet hours + */ +export type NotificationSeverity = 'low' | 'warning' | 'high'; + +/** + * Quiet Hours Service + */ +export class QuietHoursService { + /** + * Check if current time is within quiet hours for a device + * + * @param prefs - User's quiet hours preferences + * @returns true if currently in quiet hours + */ + isQuietTime(prefs: QuietHoursPrefs): boolean { + if (!prefs.quietHoursEnabled || !prefs.quietHoursStart || !prefs.quietHoursEnd) { + return false; + } + + // Get current time in user's timezone + const now = new Date(); + let userTime: string; + + try { + userTime = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: prefs.quietHoursTimezone || 'UTC', + }).format(now); + } catch { + // Invalid timezone, fall back to UTC + console.warn( + `[QuietHours] Invalid timezone "${prefs.quietHoursTimezone}", falling back to UTC` + ); + userTime = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + }).format(now); + } + + const [currentHour, currentMinute] = userTime.split(':').map(Number) as [number, number]; + const [startHour, startMinute] = prefs.quietHoursStart.split(':').map(Number) as [ + number, + number, + ]; + const [endHour, endMinute] = prefs.quietHoursEnd.split(':').map(Number) as [number, number]; + + const currentMinutes = currentHour * 60 + currentMinute; + const startMinutes = startHour * 60 + startMinute; + const endMinutes = endHour * 60 + endMinute; + + // Handle overnight quiet hours (e.g., 23:00 - 07:00) + if (startMinutes > endMinutes) { + // Quiet hours span midnight + return currentMinutes >= startMinutes || currentMinutes <= endMinutes; + } + + // Normal quiet hours within same day (e.g., 01:00 - 06:00) + return currentMinutes >= startMinutes && currentMinutes <= endMinutes; + } + + /** + * Should notification be sent given quiet hours and severity? + * + * @param prefs - User's quiet hours preferences + * @param severity - Notification severity level + * @returns true if notification should be sent + */ + shouldSend(prefs: QuietHoursPrefs, severity: NotificationSeverity): boolean { + if (!this.isQuietTime(prefs)) { + return true; // Not quiet time, always send + } + + // Critical notifications (high severity) can bypass if configured + if (prefs.quietHoursOverrideCritical && severity === 'high') { + return true; + } + + return false; // Quiet time, suppress notification + } + + /** + * Should notification be sent for a non-severity-based event? + * (e.g., session started/stopped, server down/up) + * + * Server down is treated as "high" severity by default. + * Other events are treated as "low" severity. + * + * @param prefs - User's quiet hours preferences + * @param eventType - The type of notification event + * @returns true if notification should be sent + */ + shouldSendEvent( + prefs: QuietHoursPrefs, + eventType: 'session_started' | 'session_stopped' | 'server_down' | 'server_up' + ): boolean { + // Server down is always treated as critical + if (eventType === 'server_down') { + return this.shouldSend(prefs, 'high'); + } + + // All other events are non-critical + return this.shouldSend(prefs, 'low'); + } +} + +// Export singleton instance +export const quietHoursService = new QuietHoursService(); diff --git a/apps/server/src/services/rules.ts b/apps/server/src/services/rules.ts new file mode 100644 index 0000000..7ad890a --- /dev/null +++ b/apps/server/src/services/rules.ts @@ -0,0 +1,305 @@ +/** + * Rule evaluation engine + */ + +import type { + Rule, + Session, + ViolationSeverity, + ImpossibleTravelParams, + SimultaneousLocationsParams, + DeviceVelocityParams, + ConcurrentStreamsParams, + GeoRestrictionParams, +} from '@tracearr/shared'; +import { GEOIP_CONFIG, TIME_MS } from '@tracearr/shared'; + +export interface RuleEvaluationResult { + violated: boolean; + severity: ViolationSeverity; + data: Record; +} + +export class RuleEngine { + /** + * Evaluate all active rules against a new session + */ + async evaluateSession( + session: Session, + activeRules: Rule[], + recentSessions: Session[] + ): Promise { + const results: RuleEvaluationResult[] = []; + + for (const rule of activeRules) { + // Skip rules that don't apply to this server user + if (rule.serverUserId !== null && rule.serverUserId !== session.serverUserId) { + continue; + } + + const result = await this.evaluateRule(rule, session, recentSessions); + if (result.violated) { + results.push(result); + } + } + + return results; + } + + private async evaluateRule( + rule: Rule, + session: Session, + recentSessions: Session[] + ): Promise { + switch (rule.type) { + case 'impossible_travel': + return this.checkImpossibleTravel( + session, + recentSessions, + rule.params as ImpossibleTravelParams + ); + case 'simultaneous_locations': + return this.checkSimultaneousLocations( + session, + recentSessions, + rule.params as SimultaneousLocationsParams + ); + case 'device_velocity': + return this.checkDeviceVelocity( + session, + recentSessions, + rule.params as DeviceVelocityParams + ); + case 'concurrent_streams': + return this.checkConcurrentStreams( + session, + recentSessions, + rule.params as ConcurrentStreamsParams + ); + case 'geo_restriction': + return this.checkGeoRestriction(session, rule.params as GeoRestrictionParams); + default: + return { violated: false, severity: 'low', data: {} }; + } + } + + private checkImpossibleTravel( + session: Session, + recentSessions: Session[], + params: ImpossibleTravelParams + ): RuleEvaluationResult { + // Find most recent session from same server user with different location + const userSessions = recentSessions.filter( + (s) => + s.serverUserId === session.serverUserId && + s.geoLat !== null && + s.geoLon !== null && + session.geoLat !== null && + session.geoLon !== null + ); + + for (const prevSession of userSessions) { + const distance = this.calculateDistance( + prevSession.geoLat!, + prevSession.geoLon!, + session.geoLat!, + session.geoLon! + ); + + const timeDiffHours = + (session.startedAt.getTime() - prevSession.startedAt.getTime()) / (1000 * 60 * 60); + + if (timeDiffHours > 0) { + const speed = distance / timeDiffHours; + if (speed > params.maxSpeedKmh) { + return { + violated: true, + severity: 'high', + data: { + previousLocation: { lat: prevSession.geoLat, lon: prevSession.geoLon }, + currentLocation: { lat: session.geoLat, lon: session.geoLon }, + distance, + timeDiffHours, + calculatedSpeed: speed, + maxAllowedSpeed: params.maxSpeedKmh, + }, + }; + } + } + } + + return { violated: false, severity: 'low', data: {} }; + } + + private checkSimultaneousLocations( + session: Session, + recentSessions: Session[], + params: SimultaneousLocationsParams + ): RuleEvaluationResult { + // Check for active sessions from same server user at different locations + const activeSessions = recentSessions.filter( + (s) => + s.serverUserId === session.serverUserId && + s.state === 'playing' && + s.geoLat !== null && + s.geoLon !== null && + session.geoLat !== null && + session.geoLon !== null && + // Exclude sessions from the same device (likely stale session data) + !(session.deviceId && s.deviceId && session.deviceId === s.deviceId) + ); + + // Find all sessions at different locations (distance > minDistanceKm) + const conflictingSessions = activeSessions.filter((activeSession) => { + const distance = this.calculateDistance( + activeSession.geoLat!, + activeSession.geoLon!, + session.geoLat!, + session.geoLon! + ); + return distance > params.minDistanceKm; + }); + + if (conflictingSessions.length > 0) { + // Calculate max distance for reporting + const maxDistance = Math.max( + ...conflictingSessions.map((s) => + this.calculateDistance(s.geoLat!, s.geoLon!, session.geoLat!, session.geoLon!) + ) + ); + + // Collect all unique locations (including triggering session) + const allLocations = [ + { lat: session.geoLat, lon: session.geoLon, sessionId: session.id }, + ...conflictingSessions.map((s) => ({ + lat: s.geoLat, + lon: s.geoLon, + sessionId: s.id, + })), + ]; + + // Collect all session IDs for deduplication and related sessions lookup + const relatedSessionIds = conflictingSessions.map((s) => s.id); + + return { + violated: true, + severity: 'warning', + data: { + locations: allLocations, + locationCount: allLocations.length, + distance: maxDistance, + minRequiredDistance: params.minDistanceKm, + relatedSessionIds, + }, + }; + } + + return { violated: false, severity: 'low', data: {} }; + } + + private checkDeviceVelocity( + session: Session, + recentSessions: Session[], + params: DeviceVelocityParams + ): RuleEvaluationResult { + const windowStart = new Date(session.startedAt.getTime() - params.windowHours * TIME_MS.HOUR); + + const userSessions = recentSessions.filter( + (s) => s.serverUserId === session.serverUserId && s.startedAt >= windowStart + ); + + const uniqueIps = new Set(userSessions.map((s) => s.ipAddress)); + uniqueIps.add(session.ipAddress); + + if (uniqueIps.size > params.maxIps) { + return { + violated: true, + severity: 'warning', + data: { + uniqueIpCount: uniqueIps.size, + maxAllowedIps: params.maxIps, + windowHours: params.windowHours, + ips: Array.from(uniqueIps), + }, + }; + } + + return { violated: false, severity: 'low', data: {} }; + } + + private checkConcurrentStreams( + session: Session, + recentSessions: Session[], + params: ConcurrentStreamsParams + ): RuleEvaluationResult { + const activeSessions = recentSessions.filter( + (s) => + s.serverUserId === session.serverUserId && + s.state === 'playing' && + // Exclude sessions from the same device (likely reconnects/stale sessions) + // A single device can only play one stream at a time + !(session.deviceId && s.deviceId && session.deviceId === s.deviceId) + ); + + // Add 1 for current session + const totalStreams = activeSessions.length + 1; + + if (totalStreams > params.maxStreams) { + // Collect all session IDs for deduplication and related sessions lookup + const relatedSessionIds = activeSessions.map((s) => s.id); + + return { + violated: true, + severity: 'low', + data: { + activeStreamCount: totalStreams, + maxAllowedStreams: params.maxStreams, + relatedSessionIds, + }, + }; + } + + return { violated: false, severity: 'low', data: {} }; + } + + private checkGeoRestriction( + session: Session, + params: GeoRestrictionParams + ): RuleEvaluationResult { + if (session.geoCountry && params.blockedCountries.includes(session.geoCountry)) { + return { + violated: true, + severity: 'high', + data: { + country: session.geoCountry, + blockedCountries: params.blockedCountries, + }, + }; + } + + return { violated: false, severity: 'low', data: {} }; + } + + /** + * Calculate distance between two points using Haversine formula + */ + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = GEOIP_CONFIG.EARTH_RADIUS_KM; + const dLat = this.toRadians(lat2 - lat1); + const dLon = this.toRadians(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRadians(lat1)) * + Math.cos(this.toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } +} + +export const ruleEngine = new RuleEngine(); diff --git a/apps/server/src/services/sseManager.ts b/apps/server/src/services/sseManager.ts new file mode 100644 index 0000000..5d5ee20 --- /dev/null +++ b/apps/server/src/services/sseManager.ts @@ -0,0 +1,363 @@ +/** + * SSE Connection Manager + * + * Manages Server-Sent Events connections for all Plex servers. + * Coordinates between SSE (real-time) and poller (fallback/reconciliation). + * + * Architecture: + * - Primary: SSE connections for instant session updates + * - Fallback: Polling when SSE fails or for servers that don't support SSE + * - Reconciliation: Light periodic poll to catch any missed events + */ + +import { EventEmitter } from 'events'; +import { eq } from 'drizzle-orm'; +import { + POLLING_INTERVALS, + type SSEConnectionState, + type SSEConnectionStatus, + type PlexPlaySessionNotification, +} from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { servers } from '../db/schema.js'; +import { PlexEventSource } from './mediaServer/plex/eventSource.js'; +import type { CacheService, PubSubService } from './cache.js'; + +// Events emitted by SSEManager for consumers +export interface SSEManagerEvents { + 'plex:session:playing': { serverId: string; notification: PlexPlaySessionNotification }; + 'plex:session:paused': { serverId: string; notification: PlexPlaySessionNotification }; + 'plex:session:stopped': { serverId: string; notification: PlexPlaySessionNotification }; + 'plex:session:progress': { serverId: string; notification: PlexPlaySessionNotification }; + 'connection:status': SSEConnectionStatus; + 'fallback:activated': { serverId: string; serverName: string }; + 'fallback:deactivated': { serverId: string; serverName: string }; +} + +interface ServerConnection { + serverId: string; + serverName: string; + serverType: 'plex' | 'jellyfin' | 'emby'; + eventSource: PlexEventSource | null; + state: SSEConnectionState; + inFallback: boolean; +} + +/** + * SSEManager - Centralized management of SSE connections + * + * @example + * const manager = new SSEManager(); + * await manager.initialize(cacheService, pubSubService); + * + * manager.on('plex:session:playing', ({ serverId, notification }) => { + * // Handle new/resumed playback + * }); + * + * manager.on('fallback:activated', ({ serverId }) => { + * // Enable polling for this server + * }); + */ +export class SSEManager extends EventEmitter { + private connections = new Map(); + private cacheService: CacheService | null = null; + private pubSubService: PubSubService | null = null; + private reconciliationTimer: NodeJS.Timeout | null = null; + private initialized = false; + + /** + * Initialize the SSE manager with cache services + */ + async initialize(cache: CacheService, pubSub: PubSubService): Promise { + if (this.initialized) { + return; + } + + this.cacheService = cache; + this.pubSubService = pubSub; + this.initialized = true; + + console.log('[SSEManager] Initialized'); + } + + /** + * Start SSE connections for all Plex servers + */ + async start(): Promise { + if (!this.initialized) { + throw new Error('SSEManager not initialized'); + } + + // Get all Plex servers + const allServers = await db + .select() + .from(servers) + .where(eq(servers.type, 'plex')); + + console.log(`[SSEManager] Starting SSE for ${allServers.length} Plex server(s)`); + + // Create SSE connections for each Plex server + for (const server of allServers) { + await this.addServer(server.id, server.name, server.type as 'plex', server.url, server.token); + } + + // Start reconciliation timer + this.startReconciliation(); + } + + /** + * Stop all SSE connections + */ + async stop(): Promise { + console.log('[SSEManager] Stopping all connections'); + + // Stop reconciliation + if (this.reconciliationTimer) { + clearInterval(this.reconciliationTimer); + this.reconciliationTimer = null; + } + + // Disconnect all SSE connections + for (const connection of this.connections.values()) { + if (connection.eventSource) { + connection.eventSource.disconnect(); + } + } + + this.connections.clear(); + } + + /** + * Add a server and establish SSE connection + */ + async addServer( + serverId: string, + serverName: string, + serverType: 'plex' | 'jellyfin' | 'emby', + url: string, + token: string + ): Promise { + // Remove existing connection if present + if (this.connections.has(serverId)) { + await this.removeServer(serverId); + } + + const connection: ServerConnection = { + serverId, + serverName, + serverType, + eventSource: null, + state: 'disconnected', + inFallback: false, + }; + + // Only Plex supports SSE currently + if (serverType === 'plex') { + const eventSource = new PlexEventSource({ + serverId, + serverName, + url, + token, + }); + + // Wire up event handlers + this.setupEventHandlers(eventSource, serverId, serverName); + + connection.eventSource = eventSource; + + // Connect + await eventSource.connect(); + } else { + // Jellyfin/Emby: Start in fallback mode (polling) + connection.inFallback = true; + connection.state = 'fallback'; + this.emit('fallback:activated', { serverId, serverName }); + } + + this.connections.set(serverId, connection); + } + + /** + * Remove a server and disconnect SSE + */ + async removeServer(serverId: string): Promise { + const connection = this.connections.get(serverId); + if (!connection) { + return; + } + + if (connection.eventSource) { + connection.eventSource.removeAllListeners(); + connection.eventSource.disconnect(); + } + + this.connections.delete(serverId); + console.log(`[SSEManager] Removed server ${connection.serverName}`); + } + + /** + * Get status of all connections + */ + getStatus(): SSEConnectionStatus[] { + const statuses: SSEConnectionStatus[] = []; + + for (const connection of this.connections.values()) { + if (connection.eventSource) { + statuses.push(connection.eventSource.getStatus()); + } else { + // Non-SSE server (Jellyfin/Emby) + statuses.push({ + serverId: connection.serverId, + serverName: connection.serverName, + state: connection.state, + connectedAt: null, + lastEventAt: null, + reconnectAttempts: 0, + error: null, + }); + } + } + + return statuses; + } + + /** + * Check if a server is using fallback (polling) + */ + isInFallback(serverId: string): boolean { + const connection = this.connections.get(serverId); + return connection?.inFallback ?? true; // Default to fallback if not found + } + + /** + * Get list of servers that need polling (fallback mode or non-Plex) + */ + getServersNeedingPoll(): string[] { + const serverIds: string[] = []; + + for (const connection of this.connections.values()) { + if (connection.inFallback || connection.serverType !== 'plex') { + serverIds.push(connection.serverId); + } + } + + return serverIds; + } + + /** + * Set up event handlers for a PlexEventSource + */ + private setupEventHandlers( + eventSource: PlexEventSource, + serverId: string, + serverName: string + ): void { + // Session events - forward to processor + eventSource.on('session:playing', (notification: PlexPlaySessionNotification) => { + this.emit('plex:session:playing', { serverId, notification }); + }); + + eventSource.on('session:paused', (notification: PlexPlaySessionNotification) => { + this.emit('plex:session:paused', { serverId, notification }); + }); + + eventSource.on('session:stopped', (notification: PlexPlaySessionNotification) => { + this.emit('plex:session:stopped', { serverId, notification }); + }); + + eventSource.on('session:progress', (notification: PlexPlaySessionNotification) => { + this.emit('plex:session:progress', { serverId, notification }); + }); + + // Connection state changes + eventSource.on('connection:state', (state: SSEConnectionState) => { + const connection = this.connections.get(serverId); + if (connection) { + connection.state = state; + + // Handle fallback transitions + if (state === 'fallback' && !connection.inFallback) { + connection.inFallback = true; + console.log(`[SSEManager] Server ${serverName} entering fallback mode`); + this.emit('fallback:activated', { serverId, serverName }); + } else if (state === 'connected' && connection.inFallback) { + connection.inFallback = false; + console.log(`[SSEManager] Server ${serverName} exiting fallback mode`); + this.emit('fallback:deactivated', { serverId, serverName }); + } + + // Emit status update + this.emit('connection:status', eventSource.getStatus()); + } + }); + + eventSource.on('connection:error', (error: Error) => { + console.error(`[SSEManager] Connection error for ${serverName}:`, error.message); + }); + } + + /** + * Start periodic reconciliation + * Light poll to catch any events that might have been missed + */ + private startReconciliation(): void { + if (this.reconciliationTimer) { + return; + } + + console.log(`[SSEManager] Starting reconciliation (every ${POLLING_INTERVALS.SSE_RECONCILIATION / 1000}s)`); + + this.reconciliationTimer = setInterval(() => { + this.emit('reconciliation:needed'); + }, POLLING_INTERVALS.SSE_RECONCILIATION); + } + + /** + * Manually trigger a reconnection attempt for a server + */ + async reconnect(serverId: string): Promise { + const connection = this.connections.get(serverId); + if (!connection?.eventSource) { + return; + } + + console.log(`[SSEManager] Manual reconnect for ${connection.serverName}`); + connection.eventSource.disconnect(); + await connection.eventSource.connect(); + } + + /** + * Refresh server list (call when servers are added/removed) + */ + async refresh(): Promise { + const allServers = await db + .select() + .from(servers); + + const currentServerIds = new Set(allServers.map(s => s.id)); + const connectedServerIds = new Set(this.connections.keys()); + + // Remove servers that no longer exist + for (const serverId of connectedServerIds) { + if (!currentServerIds.has(serverId)) { + await this.removeServer(serverId); + } + } + + // Add new Plex servers + for (const server of allServers) { + if (!connectedServerIds.has(server.id) && server.type === 'plex') { + await this.addServer( + server.id, + server.name, + server.type as 'plex', + server.url, + server.token + ); + } + } + } +} + +// Singleton instance +export const sseManager = new SSEManager(); diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts new file mode 100644 index 0000000..40bc18f --- /dev/null +++ b/apps/server/src/services/sync.ts @@ -0,0 +1,205 @@ +/** + * Server sync service - imports users and libraries from Plex/Jellyfin + * + * Uses generic syncServerUsers function for both Plex and Jellyfin, + * delegating user operations to userService. + */ + +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { servers } from '../db/schema.js'; +import { + createMediaServerClient, + PlexClient, + type MediaUser, +} from './mediaServer/index.js'; +import { syncUserFromMediaServer } from './userService.js'; + +export interface SyncResult { + usersAdded: number; + usersUpdated: number; + librariesSynced: number; + errors: string[]; +} + +export interface SyncOptions { + syncUsers?: boolean; + syncLibraries?: boolean; +} + +/** + * Generic user sync - works for both Plex and Jellyfin + * + * Uses userService.upsertUserFromMediaServer to handle create/update logic, + * eliminating duplicate code between syncPlexUsers and syncJellyfinUsers. + */ +async function syncServerUsers( + serverId: string, + mediaUsers: MediaUser[] +): Promise<{ added: number; updated: number; errors: string[] }> { + const errors: string[] = []; + let added = 0; + let updated = 0; + + for (const mediaUser of mediaUsers) { + try { + const result = await syncUserFromMediaServer(serverId, mediaUser); + if (result.created) { + added++; + } else { + updated++; + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + errors.push(`Failed to sync user ${mediaUser.username}: ${message}`); + } + } + + return { added, updated, errors }; +} + +/** + * Fetch Plex users from server (Plex has special API via Plex.tv) + */ +async function fetchPlexUsers(token: string, serverUrl: string): Promise { + // Get server machine identifier for shared_servers API + const response = await fetch(serverUrl, { + headers: { + 'X-Plex-Token': token, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to connect to Plex server: ${response.status}`); + } + + const serverInfo = (await response.json()) as { + MediaContainer?: { machineIdentifier?: string }; + }; + const machineIdentifier = serverInfo.MediaContainer?.machineIdentifier; + + if (!machineIdentifier) { + throw new Error('Could not get server machine identifier'); + } + + return PlexClient.getAllUsersWithLibraries(token, machineIdentifier); +} + +/** + * Sync users from Plex server to local database + */ +async function syncPlexUsers( + serverId: string, + token: string, + serverUrl: string +): Promise<{ added: number; updated: number; errors: string[] }> { + try { + const plexUsers = await fetchPlexUsers(token, serverUrl); + return syncServerUsers(serverId, plexUsers); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { added: 0, updated: 0, errors: [`Plex user sync failed: ${message}`] }; + } +} + +/** + * Sync users from Jellyfin server to local database + */ +async function syncJellyfinUsers( + serverId: string, + serverUrl: string, + encryptedToken: string +): Promise<{ added: number; updated: number; errors: string[] }> { + try { + const client = createMediaServerClient({ + type: 'jellyfin', + url: serverUrl, + token: encryptedToken, + }); + const jellyfinUsers = await client.getUsers(); + return syncServerUsers(serverId, jellyfinUsers); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { added: 0, updated: 0, errors: [`Jellyfin user sync failed: ${message}`] }; + } +} + +/** + * Sync a single server (users and libraries) + */ +export async function syncServer( + serverId: string, + options: SyncOptions = { syncUsers: true, syncLibraries: true } +): Promise { + const result: SyncResult = { + usersAdded: 0, + usersUpdated: 0, + librariesSynced: 0, + errors: [], + }; + + // Get server details + const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1); + const server = serverRows[0]; + + if (!server) { + result.errors.push(`Server not found: ${serverId}`); + return result; + } + + const token = server.token; + const serverUrl = server.url.replace(/\/$/, ''); + + // Sync users + if (options.syncUsers) { + if (server.type === 'plex') { + const userResult = await syncPlexUsers(serverId, token, serverUrl); + result.usersAdded = userResult.added; + result.usersUpdated = userResult.updated; + result.errors.push(...userResult.errors); + } else if (server.type === 'jellyfin') { + // Pass encrypted token - JellyfinService will decrypt + const userResult = await syncJellyfinUsers(serverId, serverUrl, server.token); + result.usersAdded = userResult.added; + result.usersUpdated = userResult.updated; + result.errors.push(...userResult.errors); + } + } + + // Sync libraries (just count for now - libraries stored on server) + if (options.syncLibraries) { + try { + const client = createMediaServerClient({ + type: server.type, + url: serverUrl, + token: server.token, + }); + const libraries = await client.getLibraries(); + result.librariesSynced = libraries.length; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + result.errors.push(`Library sync failed: ${message}`); + } + } + + return result; +} + +/** + * Sync all configured servers + */ +export async function syncAllServers( + options: SyncOptions = { syncUsers: true, syncLibraries: true } +): Promise> { + const results = new Map(); + + const allServers = await db.select().from(servers); + + for (const server of allServers) { + const result = await syncServer(server.id, options); + results.set(server.id, result); + } + + return results; +} diff --git a/apps/server/src/services/tautulli.ts b/apps/server/src/services/tautulli.ts new file mode 100644 index 0000000..39e9779 --- /dev/null +++ b/apps/server/src/services/tautulli.ts @@ -0,0 +1,858 @@ +/** + * Tautulli API integration and import service + */ + +import { eq, inArray, and } from 'drizzle-orm'; +import { z } from 'zod'; +import type { TautulliImportProgress, TautulliImportResult } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { sessions, serverUsers, settings } from '../db/schema.js'; +import { refreshAggregates } from '../db/timescale.js'; +import { geoipService } from './geoip.js'; +import type { PubSubService } from './cache.js'; + +const PAGE_SIZE = 5000; // Larger batches = fewer API calls (tested up to 10k, scales linearly) +const REQUEST_TIMEOUT_MS = 30000; // 30 seconds +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 1000; // Base delay, will be multiplied by attempt number + +// Helper for fields that can be number or empty string (Tautulli API inconsistency) +// Exported for testing +export const numberOrEmptyString = z.union([z.number(), z.literal('')]); + +// Zod schemas for runtime validation of Tautulli API responses +// Based on actual API response from http://192.168.1.32:8181 +// Exported for testing +export const TautulliHistoryRecordSchema = z.object({ + // IDs - can be null for active sessions + reference_id: z.number().nullable(), + row_id: z.number().nullable(), + id: z.number().nullable(), // Additional ID field + + // Timestamps and durations - always numbers + date: z.number(), + started: z.number(), + stopped: z.number(), + duration: z.number(), + play_duration: z.number(), // Actual play time + paused_counter: z.number(), + + // User info + user_id: z.number(), + user: z.string(), + friendly_name: z.string(), + user_thumb: z.string(), // User avatar URL + + // Player/client info + platform: z.string(), + product: z.string(), + player: z.string(), + ip_address: z.string(), + machine_id: z.string(), + location: z.string(), + + // Boolean-like flags (0/1) - can be null in some Tautulli versions + live: z.number().nullable(), + secure: z.number().nullable(), + relayed: z.number().nullable(), + + // Media info + media_type: z.string(), + rating_key: z.number(), // Always number per actual API + // These CAN be empty string for movies, number for episodes + parent_rating_key: numberOrEmptyString, + grandparent_rating_key: numberOrEmptyString, + full_title: z.string(), + title: z.string(), + parent_title: z.string(), + grandparent_title: z.string(), + original_title: z.string().nullable(), + // year: number for movies, empty string "" for episodes + year: numberOrEmptyString, + // media_index: number for episodes, empty string for movies + media_index: numberOrEmptyString, + parent_media_index: numberOrEmptyString, + thumb: z.string(), + originally_available_at: z.string(), + guid: z.string(), + + // Playback info + transcode_decision: z.string(), + percent_complete: z.number(), + watched_status: z.number(), // 0, 0.75, 1 + + // Session grouping + group_count: z.number().nullable(), + group_ids: z.string().nullable(), + state: z.string().nullable(), + session_key: z.number().nullable(), // Actually just number | null per API +}); + +export const TautulliHistoryResponseSchema = z.object({ + response: z.object({ + result: z.string(), + message: z.string().nullable(), + data: z.object({ + recordsFiltered: z.number(), + recordsTotal: z.number(), + data: z.array(TautulliHistoryRecordSchema), + draw: z.number(), + filter_duration: z.string(), + total_duration: z.string(), + }), + }), +}); + +export const TautulliUserRecordSchema = z.object({ + user_id: z.number(), + username: z.string(), + friendly_name: z.string(), + email: z.string().nullable(), // Can be null for local users + thumb: z.string().nullable(), // Can be null for local users + is_home_user: z.number().nullable(), // Can be null for local users + is_admin: z.number(), + is_active: z.number(), + do_notify: z.number(), +}); + +export const TautulliUsersResponseSchema = z.object({ + response: z.object({ + result: z.string(), + message: z.string().nullable(), + data: z.array(TautulliUserRecordSchema), + }), +}); + +// Infer types from schemas - exported for testing +export type TautulliHistoryRecord = z.infer; +export type TautulliHistoryResponse = z.infer; +export type TautulliUserRecord = z.infer; +export type TautulliUsersResponse = z.infer; + +export class TautulliService { + private baseUrl: string; + private apiKey: string; + + constructor(url: string, apiKey: string) { + this.baseUrl = url.replace(/\/$/, ''); + this.apiKey = apiKey; + } + + /** + * Make API request to Tautulli with timeout and retry logic + */ + private async request( + cmd: string, + params: Record = {}, + schema?: z.ZodType + ): Promise { + const url = new URL(`${this.baseUrl}/api/v2`); + url.searchParams.set('apikey', this.apiKey); + url.searchParams.set('cmd', cmd); + + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)); + } + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url.toString(), { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Tautulli API error: ${response.status} ${response.statusText}`); + } + + const json = await response.json(); + + // Validate response with Zod schema if provided + if (schema) { + const parsed = schema.safeParse(json); + if (!parsed.success) { + console.error('Tautulli API response validation failed:', z.treeifyError(parsed.error)); + throw new Error(`Invalid Tautulli API response: ${parsed.error.message}`); + } + return parsed.data; + } + + return json as T; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error) { + // Don't retry on abort (timeout) after max retries + if (error.name === 'AbortError') { + lastError = new Error(`Tautulli API timeout after ${REQUEST_TIMEOUT_MS}ms`); + } else { + lastError = error; + } + } else { + lastError = new Error('Unknown error'); + } + + // Don't retry on validation errors + if (lastError.message.includes('Invalid Tautulli API response')) { + throw lastError; + } + + // Wait before retrying (exponential backoff) + if (attempt < MAX_RETRIES) { + const delay = RETRY_DELAY_MS * attempt; + console.warn(`Tautulli API request failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError ?? new Error('Tautulli API request failed after retries'); + } + + /** + * Test connection to Tautulli + */ + async testConnection(): Promise { + try { + const result = await this.request<{ response: { result: string } }>('arnold'); + return result.response.result === 'success'; + } catch { + return false; + } + } + + /** + * Get all users from Tautulli + */ + async getUsers(): Promise { + const result = await this.request( + 'get_users', + {}, + TautulliUsersResponseSchema + ); + return result.response.data ?? []; + } + + /** + * Get paginated history from Tautulli + */ + async getHistory( + start: number = 0, + length: number = PAGE_SIZE + ): Promise<{ records: TautulliHistoryRecord[]; total: number }> { + const result = await this.request( + 'get_history', + { + start, + length, + order_column: 'date', + order_dir: 'desc', + }, + TautulliHistoryResponseSchema + ); + + return { + records: result.response.data?.data ?? [], + // Use recordsFiltered (not recordsTotal) - Tautulli applies grouping/filtering by default + total: result.response.data?.recordsFiltered ?? 0, + }; + } + + /** + * Import all history from Tautulli into Tracearr (OPTIMIZED) + * + * Performance improvements over original: + * - Pre-fetches all existing sessions (1 query vs N queries for dedup) + * - Batches INSERT operations (100 per batch vs individual inserts) + * - Batches UPDATE operations in transactions + * - Caches GeoIP lookups per IP address + * - Throttles WebSocket updates (every 100 records or 2 seconds) + * - Extends BullMQ lock on progress to prevent stalls with large imports + */ + static async importHistory( + serverId: string, + pubSubService?: PubSubService, + onProgress?: (progress: TautulliImportProgress) => Promise + ): Promise { + // Get Tautulli settings + const settingsRow = await db + .select() + .from(settings) + .where(eq(settings.id, 1)) + .limit(1); + + const config = settingsRow[0]; + if (!config?.tautulliUrl || !config?.tautulliApiKey) { + return { + success: false, + imported: 0, + updated: 0, + skipped: 0, + errors: 0, + message: 'Tautulli is not configured. Please add URL and API key in Settings.', + }; + } + + const tautulli = new TautulliService(config.tautulliUrl, config.tautulliApiKey); + + // Test connection + const connected = await tautulli.testConnection(); + if (!connected) { + return { + success: false, + imported: 0, + updated: 0, + skipped: 0, + errors: 0, + message: 'Failed to connect to Tautulli. Please check URL and API key.', + }; + } + + // Initialize progress with detailed tracking + const progress: TautulliImportProgress = { + status: 'fetching', + totalRecords: 0, + fetchedRecords: 0, + processedRecords: 0, + importedRecords: 0, + updatedRecords: 0, + skippedRecords: 0, + duplicateRecords: 0, + unknownUserRecords: 0, + activeSessionRecords: 0, + errorRecords: 0, + currentPage: 0, + totalPages: 0, + message: 'Connecting to Tautulli...', + }; + + // Throttled progress publishing (fire-and-forget, every 100 records or 2 seconds) + // Also calls onProgress callback for BullMQ lock extension + let lastProgressTime = Date.now(); + const publishProgress = () => { + if (pubSubService) { + pubSubService.publish('import:progress', progress).catch((err: unknown) => { + console.warn('Failed to publish progress:', err); + }); + } + // Call onProgress callback (extends BullMQ lock for large imports) + if (onProgress) { + onProgress(progress).catch((err: unknown) => { + console.warn('Failed to call onProgress callback:', err); + }); + } + }; + + publishProgress(); + + // Get user mapping (Tautulli user_id → Tracearr user_id) + const userMap = new Map(); + + // Get all Tracearr server users for this server + const tracearrUsers = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.serverId, serverId)); + + // Map by externalId (Plex user ID) + for (const serverUser of tracearrUsers) { + if (serverUser.externalId) { + const plexUserId = parseInt(serverUser.externalId, 10); + if (!isNaN(plexUserId)) { + userMap.set(plexUserId, serverUser.id); + } + } + } + + // Get total count + const { total } = await tautulli.getHistory(0, 1); + progress.totalRecords = total; + progress.totalPages = Math.ceil(total / PAGE_SIZE); + progress.message = `Found ${total} records to import`; + publishProgress(); + + // === MEMORY OPTIMIZATION: Per-page dedup instead of pre-loading all sessions === + // This uses constant memory regardless of total import size (critical for 300k+ imports) + // Trade-off: One extra query per page, but avoids loading 300k+ sessions into memory + interface ExistingSession { + id: string; + externalSessionId: string | null; + ratingKey: string | null; + startedAt: Date | null; + serverUserId: string; + totalDurationMs: number | null; + stoppedAt: Date | null; + durationMs: number | null; + pausedDurationMs: number | null; + watched: boolean | null; + } + + // Track externalSessionIds we've already inserted in THIS import run + // (prevents duplicates within the same import when records appear on multiple pages) + const insertedThisRun = new Set(); + + // Helper to query existing sessions for a batch of reference IDs + const queryExistingByRefIds = async ( + refIds: string[] + ): Promise> => { + if (refIds.length === 0) return new Map(); + + const existing = await db + .select({ + id: sessions.id, + externalSessionId: sessions.externalSessionId, + ratingKey: sessions.ratingKey, + startedAt: sessions.startedAt, + serverUserId: sessions.serverUserId, + totalDurationMs: sessions.totalDurationMs, + stoppedAt: sessions.stoppedAt, + durationMs: sessions.durationMs, + pausedDurationMs: sessions.pausedDurationMs, + watched: sessions.watched, + }) + .from(sessions) + .where( + and(eq(sessions.serverId, serverId), inArray(sessions.externalSessionId, refIds)) + ); + + const map = new Map(); + for (const s of existing) { + if (s.externalSessionId) { + map.set(s.externalSessionId, s); + } + } + return map; + }; + + // Helper to query existing sessions by time-based key (fallback dedup) + const queryExistingByTimeKeys = async ( + keys: Array<{ serverUserId: string; ratingKey: string; startedAt: Date }> + ): Promise> => { + if (keys.length === 0) return new Map(); + + // Build OR conditions for each key + // This is less efficient than IN but necessary for composite key matching + const existing = await db + .select({ + id: sessions.id, + externalSessionId: sessions.externalSessionId, + ratingKey: sessions.ratingKey, + startedAt: sessions.startedAt, + serverUserId: sessions.serverUserId, + totalDurationMs: sessions.totalDurationMs, + stoppedAt: sessions.stoppedAt, + durationMs: sessions.durationMs, + pausedDurationMs: sessions.pausedDurationMs, + watched: sessions.watched, + }) + .from(sessions) + .where( + and( + eq(sessions.serverId, serverId), + inArray( + sessions.ratingKey, + keys.map((k) => k.ratingKey) + ), + inArray( + sessions.serverUserId, + [...new Set(keys.map((k) => k.serverUserId))] + ) + ) + ); + + const map = new Map(); + for (const s of existing) { + if (s.ratingKey && s.serverUserId && s.startedAt) { + const timeKey = `${s.serverUserId}:${s.ratingKey}:${s.startedAt.getTime()}`; + map.set(timeKey, s); + } + } + return map; + }; + + console.log('[Import] Using per-page dedup queries (memory-efficient mode)'); + + // === OPTIMIZATION: GeoIP cache (bounded - cleared every 50 pages to prevent unbounded growth) === + let geoCache = new Map>(); + + // === OPTIMIZATION: Batch collections === + // Inserts are batched per page (100 records) and flushed at end of each page + const insertBatch: (typeof sessions.$inferInsert)[] = []; + + // Update batches - collected and flushed per page + interface SessionUpdate { + id: string; + externalSessionId?: string; + stoppedAt: Date; + durationMs: number; + pausedDurationMs: number; + watched: boolean; + progressMs?: number; + } + const updateBatch: SessionUpdate[] = []; + + let imported = 0; + let updated = 0; + let skipped = 0; + let errors = 0; + let page = 0; + + // Track skipped users for warning message + const skippedUsers = new Map(); + + // Helper to flush batches + const flushBatches = async () => { + // Flush inserts in chunks (drizzle-orm has stack overflow with large arrays due to spread operator) + // See: https://github.com/drizzle-team/drizzle-orm/issues/1740 + if (insertBatch.length > 0) { + const INSERT_CHUNK_SIZE = 500; + for (let i = 0; i < insertBatch.length; i += INSERT_CHUNK_SIZE) { + const chunk = insertBatch.slice(i, i + INSERT_CHUNK_SIZE); + await db.insert(sessions).values(chunk); + } + insertBatch.length = 0; + } + + // Flush updates in parallel chunks (much faster than sequential transaction) + // Each update is independent, so we can safely parallelize + // Pool has max 20 connections - chunks of 100 will queue but process efficiently + if (updateBatch.length > 0) { + const UPDATE_CHUNK_SIZE = 100; + for (let i = 0; i < updateBatch.length; i += UPDATE_CHUNK_SIZE) { + const chunk = updateBatch.slice(i, i + UPDATE_CHUNK_SIZE); + await Promise.all( + chunk.map((update) => + db + .update(sessions) + .set({ + externalSessionId: update.externalSessionId, + stoppedAt: update.stoppedAt, + durationMs: update.durationMs, + pausedDurationMs: update.pausedDurationMs, + watched: update.watched, + progressMs: update.progressMs, + }) + .where(eq(sessions.id, update.id)) + ) + ); + } + updateBatch.length = 0; + } + }; + + // Process all pages + while (page * PAGE_SIZE < total) { + progress.status = 'processing'; + progress.currentPage = page + 1; + progress.message = `Processing page ${page + 1} of ${progress.totalPages}`; + + // Clear geo cache periodically to prevent unbounded growth (every 10 pages) + if (page > 0 && page % 10 === 0) { + geoCache = new Map(); + } + + const { records } = await tautulli.getHistory(page * PAGE_SIZE, PAGE_SIZE); + + // Track actual records fetched (may differ from API total if records changed) + progress.fetchedRecords += records.length; + + // === MEMORY OPTIMIZATION: Per-page dedup queries === + // Extract all reference IDs from this page for batch dedup query + const pageRefIds: string[] = []; + const pageTimeKeys: Array<{ serverUserId: string; ratingKey: string; startedAt: Date }> = []; + + for (const record of records) { + if (record.reference_id !== null) { + pageRefIds.push(String(record.reference_id)); + } + // Collect time-based keys for fallback dedup + const serverUserId = userMap.get(record.user_id); + const ratingKey = typeof record.rating_key === 'number' ? String(record.rating_key) : null; + if (serverUserId && ratingKey) { + pageTimeKeys.push({ + serverUserId, + ratingKey, + startedAt: new Date(record.started * 1000), + }); + } + } + + // Query existing sessions for this page (2 queries per page max) + const sessionByExternalId = await queryExistingByRefIds(pageRefIds); + const sessionByTimeKey = await queryExistingByTimeKeys(pageTimeKeys); + + for (const record of records) { + progress.processedRecords++; + + try { + // Find Tracearr server user by Plex user ID + const serverUserId = userMap.get(record.user_id); + if (!serverUserId) { + // User not found in Tracearr - track for warning + const existing = skippedUsers.get(record.user_id); + if (existing) { + existing.count++; + } else { + skippedUsers.set(record.user_id, { + username: record.friendly_name || record.user, + count: 1, + }); + } + skipped++; + progress.skippedRecords++; + progress.unknownUserRecords++; + continue; + } + + // Skip records without reference_id (active/in-progress sessions) + if (record.reference_id === null) { + skipped++; + progress.skippedRecords++; + progress.activeSessionRecords++; + continue; + } + + const referenceIdStr = String(record.reference_id); + + // Skip if we already inserted this in a previous page of THIS import run + if (insertedThisRun.has(referenceIdStr)) { + skipped++; + progress.skippedRecords++; + progress.duplicateRecords++; + continue; + } + + // Check if exists in database (per-page query result) + const existingByRef = sessionByExternalId.get(referenceIdStr); + if (existingByRef) { + // Calculate new values + const newStoppedAt = new Date(record.stopped * 1000); + const newDurationMs = record.duration * 1000; + const newPausedDurationMs = record.paused_counter * 1000; + const newWatched = record.watched_status === 1; + const newProgressMs = Math.round( + (record.percent_complete / 100) * (existingByRef.totalDurationMs ?? 0) + ); + + // Only update if something actually changed + const stoppedAtChanged = existingByRef.stoppedAt?.getTime() !== newStoppedAt.getTime(); + const durationChanged = existingByRef.durationMs !== newDurationMs; + const pausedChanged = existingByRef.pausedDurationMs !== newPausedDurationMs; + const watchedChanged = existingByRef.watched !== newWatched; + + if (stoppedAtChanged || durationChanged || pausedChanged || watchedChanged) { + updateBatch.push({ + id: existingByRef.id, + stoppedAt: newStoppedAt, + durationMs: newDurationMs, + pausedDurationMs: newPausedDurationMs, + watched: newWatched, + progressMs: newProgressMs, + }); + updated++; + progress.updatedRecords++; + } else { + // No changes needed - true duplicate + skipped++; + progress.skippedRecords++; + progress.duplicateRecords++; + } + continue; + } + + // Fallback dedup check by time-based key + const startedAt = new Date(record.started * 1000); + const ratingKeyStr = + typeof record.rating_key === 'number' ? String(record.rating_key) : null; + + if (ratingKeyStr) { + const timeKey = `${serverUserId}:${ratingKeyStr}:${startedAt.getTime()}`; + const existingByTime = sessionByTimeKey.get(timeKey); + + if (existingByTime) { + // Calculate new values + const newStoppedAt = new Date(record.stopped * 1000); + const newDurationMs = record.duration * 1000; + const newPausedDurationMs = record.paused_counter * 1000; + const newWatched = record.watched_status === 1; + + // Check if externalSessionId needs to be set (fallback match means it was missing) + const needsExternalId = !existingByTime.externalSessionId; + + // Check if other fields changed + const stoppedAtChanged = existingByTime.stoppedAt?.getTime() !== newStoppedAt.getTime(); + const durationChanged = existingByTime.durationMs !== newDurationMs; + const pausedChanged = existingByTime.pausedDurationMs !== newPausedDurationMs; + const watchedChanged = existingByTime.watched !== newWatched; + + // Only update if externalSessionId is missing OR something actually changed + if (needsExternalId || stoppedAtChanged || durationChanged || pausedChanged || watchedChanged) { + updateBatch.push({ + id: existingByTime.id, + externalSessionId: referenceIdStr, + stoppedAt: newStoppedAt, + durationMs: newDurationMs, + pausedDurationMs: newPausedDurationMs, + watched: newWatched, + }); + updated++; + progress.updatedRecords++; + } else { + // No changes needed - true duplicate + skipped++; + progress.skippedRecords++; + progress.duplicateRecords++; + } + continue; + } + } + + // === OPTIMIZATION: Cached GeoIP lookup === + let geo = geoCache.get(record.ip_address); + if (!geo) { + geo = geoipService.lookup(record.ip_address); + geoCache.set(record.ip_address, geo); + } + + // Map media type + let mediaType: 'movie' | 'episode' | 'track' = 'movie'; + if (record.media_type === 'episode') { + mediaType = 'episode'; + } else if (record.media_type === 'track') { + mediaType = 'track'; + } + + const sessionKey = + record.session_key != null + ? String(record.session_key) + : `tautulli-${record.reference_id}`; + + // Track this insert to prevent duplicates within this import run + insertedThisRun.add(referenceIdStr); + + // === OPTIMIZATION: Collect insert instead of executing === + insertBatch.push({ + serverId, + serverUserId, + sessionKey, + ratingKey: ratingKeyStr, + externalSessionId: referenceIdStr, + state: 'stopped', + mediaType, + mediaTitle: record.full_title || record.title, + grandparentTitle: record.grandparent_title || null, + seasonNumber: + typeof record.parent_media_index === 'number' ? record.parent_media_index : null, + episodeNumber: typeof record.media_index === 'number' ? record.media_index : null, + year: record.year || null, + thumbPath: record.thumb || null, + startedAt, + lastSeenAt: startedAt, + stoppedAt: new Date(record.stopped * 1000), + durationMs: record.duration * 1000, + totalDurationMs: null, + progressMs: null, + pausedDurationMs: record.paused_counter * 1000, + watched: record.watched_status === 1, + ipAddress: record.ip_address || '0.0.0.0', + geoCity: geo.city, + geoRegion: geo.region, + geoCountry: geo.country, + geoLat: geo.lat, + geoLon: geo.lon, + playerName: record.player || record.product, + deviceId: record.machine_id || null, + product: record.product || null, + platform: record.platform, + quality: record.transcode_decision === 'transcode' ? 'Transcode' : 'Direct', + isTranscode: record.transcode_decision === 'transcode', + bitrate: null, + }); + + imported++; + progress.importedRecords++; + } catch (error) { + console.error('Error processing record:', record.reference_id, error); + errors++; + progress.errorRecords++; + } + + // === OPTIMIZATION: Throttled progress updates === + const now = Date.now(); + if (progress.processedRecords % 100 === 0 || now - lastProgressTime > 2000) { + publishProgress(); + lastProgressTime = now; + } + } + + // Flush batches at end of each page + await flushBatches(); + + page++; + } + + // Final flush for any remaining records + await flushBatches(); + + // Refresh TimescaleDB aggregates so imported data appears in stats immediately + progress.message = 'Refreshing aggregates...'; + publishProgress(); + try { + await refreshAggregates(); + } catch (err) { + console.warn('Failed to refresh aggregates after import:', err); + } + + // Build final message with detailed breakdown + const parts: string[] = []; + if (imported > 0) parts.push(`${imported} new`); + if (updated > 0) parts.push(`${updated} updated`); + if (skipped > 0) parts.push(`${skipped} skipped`); + if (errors > 0) parts.push(`${errors} errors`); + + let message = `Import complete: ${parts.join(', ')}`; + + if (skippedUsers.size > 0) { + const skippedUserList = [...skippedUsers.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 5) // Show top 5 skipped users + .map((u) => `${u.username} (${u.count} records)`) + .join(', '); + + const moreUsers = skippedUsers.size > 5 ? ` and ${skippedUsers.size - 5} more` : ''; + message += `. Warning: ${skippedUsers.size} users not found in Tracearr: ${skippedUserList}${moreUsers}. Sync your server to import these users first.`; + + console.warn( + `Tautulli import skipped users: ${[...skippedUsers.values()].map((u) => u.username).join(', ')}` + ); + } + + // Final progress update + progress.status = 'complete'; + progress.message = message; + publishProgress(); + + return { + success: true, + imported, + updated, + skipped, + errors, + message, + skippedUsers: + skippedUsers.size > 0 + ? [...skippedUsers.entries()].map(([id, data]) => ({ + tautulliUserId: id, + username: data.username, + recordCount: data.count, + })) + : undefined, + }; + } +} diff --git a/apps/server/src/services/termination.ts b/apps/server/src/services/termination.ts new file mode 100644 index 0000000..2b49940 --- /dev/null +++ b/apps/server/src/services/termination.ts @@ -0,0 +1,221 @@ +/** + * Stream Termination Service + * + * Orchestrates stream termination across media servers and logs all termination events. + * Supports both manual (admin-initiated) and automatic (rule-triggered) terminations. + */ + +import { eq, desc } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { terminationLogs, sessions } from '../db/schema.js'; +import { createMediaServerClient } from './mediaServer/index.js'; +import type { ServerType } from '@tracearr/shared'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface TerminateSessionOptions { + /** Database session ID (UUID) */ + sessionId: string; + + /** How the termination was triggered */ + trigger: 'manual' | 'rule'; + + /** For manual: user ID who initiated the termination */ + triggeredByUserId?: string; + + /** For rule: the rule that triggered termination */ + ruleId?: string; + + /** For rule: the violation record */ + violationId?: string; + + /** Message to display to user (Plex only, ignored by Jellyfin/Emby) */ + reason?: string; +} + +export interface TerminationResult { + success: boolean; + terminationLogId: string; + error?: string; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Terminate a stream and log the termination + * + * @param options - Termination options including session ID and trigger info + * @returns Result indicating success/failure and the termination log ID + * + * @example + * // Manual termination by admin + * const result = await terminateSession({ + * sessionId: 'uuid-123', + * trigger: 'manual', + * triggeredByUserId: adminUser.id, + * reason: 'Admin requested stream stop', + * }); + * + * @example + * // Rule-triggered termination + * const result = await terminateSession({ + * sessionId: 'uuid-123', + * trigger: 'rule', + * ruleId: rule.id, + * violationId: violation.id, + * reason: 'Concurrent stream limit exceeded', + * }); + */ +export async function terminateSession( + options: TerminateSessionOptions +): Promise { + const { sessionId, trigger, triggeredByUserId, ruleId, violationId, reason } = options; + + // Fetch session with server info + const session = await db.query.sessions.findFirst({ + where: eq(sessions.id, sessionId), + with: { + server: true, + serverUser: true, + }, + }); + + if (!session) { + // Don't log when session not found - we have no audit context (no server/user) + // The route handler validates session exists before calling this service, + // so this case only happens on race conditions or bugs + return { + success: false, + terminationLogId: '', + error: 'Session not found in database', + }; + } + + // Create media server client + const client = createMediaServerClient({ + type: session.server.type as ServerType, + url: session.server.url, + token: session.server.token, + }); + + // Get the session ID for termination + // For Plex: use plexSessionId (Session.id) which is different from sessionKey + // For Jellyfin/Emby: use sessionKey directly + const terminationSessionId = + session.server.type === 'plex' ? session.plexSessionId : session.sessionKey; + + if (!terminationSessionId) { + const logEntries = await db + .insert(terminationLogs) + .values({ + sessionId: session.id, + serverId: session.serverId, + serverUserId: session.serverUserId, + trigger, + triggeredByUserId: triggeredByUserId ?? null, + ruleId: ruleId ?? null, + violationId: violationId ?? null, + reason: reason ?? null, + success: false, + errorMessage: 'No session ID available for termination', + }) + .returning({ id: terminationLogs.id }); + + return { + success: false, + terminationLogId: logEntries[0]?.id ?? '', + error: 'No session ID available for termination', + }; + } + + // Attempt to terminate the session + let success = false; + let errorMessage: string | null = null; + + try { + await client.terminateSession(terminationSessionId, reason); + success = true; + } catch (err) { + errorMessage = err instanceof Error ? err.message : 'Unknown error during termination'; + } + + // If termination was successful, mark the session as stopped in our database + if (success) { + const now = new Date(); + const startedAt = session.startedAt ? new Date(session.startedAt) : now; + const durationMs = now.getTime() - startedAt.getTime(); + + await db + .update(sessions) + .set({ + state: 'stopped', + stoppedAt: now, + durationMs, + forceStopped: true, + }) + .where(eq(sessions.id, session.id)); + } + + // Log the termination attempt + const logEntries = await db + .insert(terminationLogs) + .values({ + sessionId: session.id, + serverId: session.serverId, + serverUserId: session.serverUserId, + trigger, + triggeredByUserId: triggeredByUserId ?? null, + ruleId: ruleId ?? null, + violationId: violationId ?? null, + reason: reason ?? null, + success, + errorMessage, + }) + .returning({ id: terminationLogs.id }); + + return { + success, + terminationLogId: logEntries[0]?.id ?? '', + error: errorMessage ?? undefined, + }; +} + +/** + * Get termination logs for a server + */ +export async function getTerminationLogs( + serverId: string, + options?: { limit?: number } +): Promise { + return db.query.terminationLogs.findMany({ + where: eq(terminationLogs.serverId, serverId), + orderBy: [desc(terminationLogs.createdAt)], + limit: options?.limit ?? 100, + with: { + session: true, + serverUser: true, + triggeredByUser: true, + rule: true, + }, + }); +} + +/** + * Get termination logs for a specific session + */ +export async function getSessionTerminationLogs( + sessionId: string +): Promise { + return db.query.terminationLogs.findMany({ + where: eq(terminationLogs.sessionId, sessionId), + orderBy: [desc(terminationLogs.createdAt)], + with: { + triggeredByUser: true, + rule: true, + }, + }); +} diff --git a/apps/server/src/services/userService.ts b/apps/server/src/services/userService.ts new file mode 100644 index 0000000..7caf0e8 --- /dev/null +++ b/apps/server/src/services/userService.ts @@ -0,0 +1,696 @@ +/** + * User Service + * + * Handles operations for the multi-server user architecture: + * - `users` = Identity (the real human) + * - `server_users` = Account on a specific server (Plex/Jellyfin/Emby) + * + * Key patterns: + * - Get operations return User/ServerUser | null for flexibility + * - Require operations throw NotFoundError for fail-fast behavior + * - Sync operations handle auto-linking by email + */ + +import { eq, and, sql } from 'drizzle-orm'; +import type { MediaUser } from './mediaServer/index.js'; +import type { UserRole } from '@tracearr/shared'; +import { db } from '../db/client.js'; +import { users, serverUsers, servers, sessions } from '../db/schema.js'; +import { NotFoundError } from '../utils/errors.js'; + +// Type for user identity table row +export type User = typeof users.$inferSelect; + +// Type for server user table row +export type ServerUser = typeof serverUsers.$inferSelect; + +// Type for server user with user and server info +export interface ServerUserWithDetails { + id: string; + userId: string; + serverId: string; + externalId: string; + username: string; + email: string | null; + thumbUrl: string | null; + isServerAdmin: boolean; + trustScore: number; + sessionCount: number; + createdAt: Date; + updatedAt: Date; + // User identity info + user: { + id: string; + name: string | null; + thumbnail: string | null; + email: string | null; + role: UserRole; + aggregateTrustScore: number; + }; + // Server info + server: { + id: string; + name: string; + type: string; + }; +} + +// Type for user with stats (for user detail page) +export interface UserWithStats { + id: string; + username: string; + name: string | null; + thumbnail: string | null; + email: string | null; + role: UserRole; + aggregateTrustScore: number; + totalViolations: number; + createdAt: Date; + updatedAt: Date; + serverUsers: Array<{ + id: string; + serverId: string; + serverName: string; + serverType: string; + username: string; + thumbUrl: string | null; + trustScore: number; + sessionCount: number; + }>; + stats: { + totalSessions: number; + totalWatchTime: number; + }; +} + +// ============================================================================ +// User Identity Operations +// ============================================================================ + +/** + * Get user identity by ID (returns null if not found) + */ +export async function getUserById(id: string): Promise { + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ?? null; +} + +/** + * Get user identity by ID (throws if not found) + */ +export async function requireUserById(id: string): Promise { + const user = await getUserById(id); + if (!user) { + throw new UserNotFoundError(id); + } + return user; +} + +/** + * Get user identity by email (for auto-linking during sync) + */ +export async function getUserByEmail(email: string): Promise { + const rows = await db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + return rows[0] ?? null; +} + +/** + * Get user identity by username (for local auth lookup) + */ +export async function getUserByUsername(username: string): Promise { + const rows = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + return rows[0] ?? null; +} + +/** + * Get user identity by Plex account ID (for Login with Plex) + */ +export async function getUserByPlexAccountId(plexAccountId: string): Promise { + const rows = await db + .select() + .from(users) + .where(eq(users.plexAccountId, plexAccountId)) + .limit(1); + return rows[0] ?? null; +} + +/** + * Get the owner user (for auth setup validation) + */ +export async function getOwnerUser(): Promise { + const rows = await db + .select() + .from(users) + .where(eq(users.role, 'owner')) + .limit(1); + return rows[0] ?? null; +} + +/** + * Create a new user identity + */ +export async function createUser(data: { + username: string; + name?: string; + email?: string; + thumbnail?: string; + passwordHash?: string; + plexAccountId?: string; + role?: UserRole; +}): Promise { + const rows = await db + .insert(users) + .values({ + username: data.username, + name: data.name ?? null, + email: data.email?.toLowerCase() ?? null, + thumbnail: data.thumbnail ?? null, + passwordHash: data.passwordHash ?? null, + plexAccountId: data.plexAccountId ?? null, + role: data.role ?? 'member', + }) + .returning(); + return rows[0]!; +} + +/** + * Create owner user (for initial setup) + */ +export async function createOwnerUser(data: { + username: string; + name?: string; + passwordHash?: string; + email?: string; + plexAccountId?: string; + thumbnail?: string; +}): Promise { + return createUser({ + ...data, + role: 'owner', + }); +} + +/** + * Update user identity + */ +export async function updateUser( + userId: string, + data: Partial<{ + username: string; + name: string | null; + email: string | null; + thumbnail: string | null; + passwordHash: string | null; + plexAccountId: string | null; + }> +): Promise { + const rows = await db + .update(users) + .set({ + ...data, + email: data.email?.toLowerCase() ?? data.email, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + .returning(); + + const user = rows[0]; + if (!user) { + throw new UserNotFoundError(userId); + } + return user; +} + +/** + * Link Plex account to existing user + */ +export async function linkPlexAccount( + userId: string, + plexAccountId: string, + thumbnail?: string +): Promise { + return updateUser(userId, { + plexAccountId, + thumbnail: thumbnail ?? undefined, + }); +} + +// ============================================================================ +// Server User Operations +// ============================================================================ + +/** + * Get server user by ID + */ +export async function getServerUserById(id: string): Promise { + const rows = await db.select().from(serverUsers).where(eq(serverUsers.id, id)).limit(1); + return rows[0] ?? null; +} + +/** + * Get server user by ID (throws if not found) + */ +export async function requireServerUserById(id: string): Promise { + const serverUser = await getServerUserById(id); + if (!serverUser) { + throw new ServerUserNotFoundError(id); + } + return serverUser; +} + +/** + * Get server user by server ID and external ID (Plex/Jellyfin user ID) + */ +export async function getServerUserByExternalId( + serverId: string, + externalId: string +): Promise { + const rows = await db + .select() + .from(serverUsers) + .where(and(eq(serverUsers.serverId, serverId), eq(serverUsers.externalId, externalId))) + .limit(1); + return rows[0] ?? null; +} + +/** + * Get server user with full details (user identity + server info) + */ +export async function getServerUserWithDetails(id: string): Promise { + const rows = await db + .select({ + id: serverUsers.id, + userId: serverUsers.userId, + serverId: serverUsers.serverId, + externalId: serverUsers.externalId, + username: serverUsers.username, + email: serverUsers.email, + thumbUrl: serverUsers.thumbUrl, + isServerAdmin: serverUsers.isServerAdmin, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + createdAt: serverUsers.createdAt, + updatedAt: serverUsers.updatedAt, + userName: users.name, + userThumbnail: users.thumbnail, + userEmail: users.email, + userRole: users.role, + userAggregateTrustScore: users.aggregateTrustScore, + serverName: servers.name, + serverType: servers.type, + }) + .from(serverUsers) + .innerJoin(users, eq(serverUsers.userId, users.id)) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .where(eq(serverUsers.id, id)) + .limit(1); + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + userId: row.userId, + serverId: row.serverId, + externalId: row.externalId, + username: row.username, + email: row.email, + thumbUrl: row.thumbUrl, + isServerAdmin: row.isServerAdmin, + trustScore: row.trustScore, + sessionCount: row.sessionCount, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + user: { + id: row.userId, + name: row.userName, + thumbnail: row.userThumbnail, + email: row.userEmail, + role: row.userRole, + aggregateTrustScore: row.userAggregateTrustScore, + }, + server: { + id: row.serverId, + name: row.serverName, + type: row.serverType, + }, + }; +} + +/** + * Get all server users for a server (for batch processing in poller) + * Returns a Map keyed by externalId for O(1) lookups + */ +export async function getServerUsersByServer(serverId: string): Promise> { + const rows = await db + .select() + .from(serverUsers) + .where(eq(serverUsers.serverId, serverId)); + + const userMap = new Map(); + for (const su of rows) { + userMap.set(su.externalId, su); + } + return userMap; +} + +/** + * Get all server users for a user identity + */ +export async function getServerUsersByUserId(userId: string): Promise { + return db + .select() + .from(serverUsers) + .where(eq(serverUsers.userId, userId)); +} + +/** + * Create a server user linked to a user identity + */ +export async function createServerUser(data: { + userId: string; + serverId: string; + externalId: string; + username: string; + email?: string; + thumbUrl?: string; + isServerAdmin?: boolean; +}): Promise { + const rows = await db + .insert(serverUsers) + .values({ + userId: data.userId, + serverId: data.serverId, + externalId: data.externalId, + username: data.username, + email: data.email ?? null, + thumbUrl: data.thumbUrl ?? null, + isServerAdmin: data.isServerAdmin ?? false, + }) + .returning(); + return rows[0]!; +} + +/** + * Update server user from media server data + */ +export async function updateServerUser( + serverUserId: string, + data: Partial<{ + username: string; + email: string | null; + thumbUrl: string | null; + isServerAdmin: boolean; + }> +): Promise { + const rows = await db + .update(serverUsers) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, serverUserId)) + .returning(); + + const serverUser = rows[0]; + if (!serverUser) { + throw new ServerUserNotFoundError(serverUserId); + } + return serverUser; +} + +/** + * Update server user trust score + */ +export async function updateServerUserTrustScore( + serverUserId: string, + trustScore: number +): Promise { + const rows = await db + .update(serverUsers) + .set({ + trustScore, + updatedAt: new Date(), + }) + .where(eq(serverUsers.id, serverUserId)) + .returning(); + + const serverUser = rows[0]; + if (!serverUser) { + throw new ServerUserNotFoundError(serverUserId); + } + return serverUser; +} + +/** + * Increment server user session count + */ +export async function incrementServerUserSessionCount(serverUserId: string): Promise { + await db + .update(serverUsers) + .set({ + sessionCount: sql`${serverUsers.sessionCount} + 1`, + }) + .where(eq(serverUsers.id, serverUserId)); +} + +// ============================================================================ +// Sync Operations (Creates both user identity and server user) +// ============================================================================ + +/** + * Sync a user from media server - handles auto-linking by email + * + * Flow: + * 1. Check if server_user exists by (serverId, externalId) + * 2. If exists: update server_user + * 3. If new: + * a. Try to find existing user identity by email match + * b. If no match: create new user identity + * c. Create server_user linked to user + * + * Returns { serverUser, user, created: boolean } + */ +export async function syncUserFromMediaServer( + serverId: string, + mediaUser: MediaUser +): Promise<{ serverUser: ServerUser; user: User; created: boolean }> { + // Check for existing server user + const existing = await getServerUserByExternalId(serverId, mediaUser.id); + + if (existing) { + // Update existing server user + const updated = await updateServerUser(existing.id, { + username: mediaUser.username, + email: mediaUser.email ?? null, + thumbUrl: mediaUser.thumb ?? null, + isServerAdmin: mediaUser.isAdmin, + }); + + const user = await requireUserById(existing.userId); + return { serverUser: updated, user, created: false }; + } + + // Use transaction to prevent orphaned users if server user creation fails + // This ensures atomicity: either both user + server_user are created, or neither + const result = await db.transaction(async (tx) => { + let user: User | undefined; + + // Try to find existing user by email match + if (mediaUser.email) { + const [existingUser] = await tx + .select() + .from(users) + .where(eq(users.email, mediaUser.email)) + .limit(1); + user = existingUser; + } + + // No match - create new user identity + if (!user) { + const [newUser] = await tx + .insert(users) + .values({ + username: mediaUser.username, // Use media server username as identity username + name: null, + email: mediaUser.email ?? null, + thumbnail: mediaUser.thumb ?? null, + }) + .returning(); + user = newUser!; // Insert always returns a row + } + + // Create server user linked to user identity + const [serverUser] = await tx + .insert(serverUsers) + .values({ + userId: user.id, + serverId, + externalId: mediaUser.id, + username: mediaUser.username, + email: mediaUser.email ?? null, + thumbUrl: mediaUser.thumb ?? null, + isServerAdmin: mediaUser.isAdmin, + }) + .returning(); + + return { serverUser: serverUser!, user }; + }); + + return { serverUser: result.serverUser, user: result.user, created: true }; +} + +/** + * Batch sync users from media server + * More efficient than individual syncs - uses batch lookups + */ +export async function batchSyncUsersFromMediaServer( + serverId: string, + mediaUsers: MediaUser[] +): Promise<{ added: number; updated: number }> { + if (mediaUsers.length === 0) return { added: 0, updated: 0 }; + + let added = 0; + let updated = 0; + + for (const mediaUser of mediaUsers) { + const result = await syncUserFromMediaServer(serverId, mediaUser); + if (result.created) { + added++; + } else { + updated++; + } + } + + return { added, updated }; +} + +// ============================================================================ +// Aggregated User Operations (across all server users) +// ============================================================================ + +/** + * Get user with stats (for user detail page) + */ +export async function getUserWithStats(userId: string): Promise { + const user = await getUserById(userId); + if (!user) return null; + + // Get all server users for this user + const serverUserRows = await db + .select({ + id: serverUsers.id, + serverId: serverUsers.serverId, + serverName: servers.name, + serverType: servers.type, + username: serverUsers.username, + thumbUrl: serverUsers.thumbUrl, + trustScore: serverUsers.trustScore, + sessionCount: serverUsers.sessionCount, + }) + .from(serverUsers) + .innerJoin(servers, eq(serverUsers.serverId, servers.id)) + .where(eq(serverUsers.userId, userId)); + + // Get aggregated stats across all server users + const serverUserIds = serverUserRows.map((su) => su.id); + let totalSessions = 0; + let totalWatchTime = 0; + + if (serverUserIds.length > 0) { + // Build explicit PostgreSQL array literal (Drizzle doesn't auto-convert JS arrays for ANY()) + const serverUserIdArray = sql.raw(`ARRAY[${serverUserIds.map(id => `'${id}'::uuid`).join(',')}]`); + const statsResult = await db + .select({ + totalSessions: sql`count(*)::int`, + totalWatchTime: sql`coalesce(sum(duration_ms), 0)::bigint`, + }) + .from(sessions) + .where(sql`${sessions.serverUserId} = ANY(${serverUserIdArray})`); + + const stats = statsResult[0]; + totalSessions = stats?.totalSessions ?? 0; + totalWatchTime = Number(stats?.totalWatchTime ?? 0); + } + + return { + id: user.id, + username: user.username, + name: user.name, + thumbnail: user.thumbnail, + email: user.email, + role: user.role, + aggregateTrustScore: user.aggregateTrustScore, + totalViolations: user.totalViolations, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + serverUsers: serverUserRows, + stats: { + totalSessions, + totalWatchTime, + }, + }; +} + +/** + * Recalculate aggregate trust score for a user + * Called by triggers when server_user trust scores change + */ +export async function recalculateAggregateTrustScore(userId: string): Promise { + // Calculate weighted average by session count + const result = await db + .select({ + weightedSum: sql`coalesce(sum(${serverUsers.trustScore}::numeric * ${serverUsers.sessionCount}), 0)`, + totalSessions: sql`coalesce(sum(${serverUsers.sessionCount}), 0)`, + }) + .from(serverUsers) + .where(eq(serverUsers.userId, userId)); + + const { weightedSum, totalSessions } = result[0] ?? { weightedSum: 0, totalSessions: 0 }; + + // Calculate aggregate score (default to 100 if no sessions) + const aggregateScore = + totalSessions > 0 ? Math.round(Number(weightedSum) / Number(totalSessions)) : 100; + + await db + .update(users) + .set({ + aggregateTrustScore: aggregateScore, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); +} + +// ============================================================================ +// Errors +// ============================================================================ + +/** + * User not found error - extends NotFoundError for consistent error handling. + */ +export class UserNotFoundError extends NotFoundError { + constructor(id?: string) { + super('User', id); + this.name = 'UserNotFoundError'; + Object.setPrototypeOf(this, UserNotFoundError.prototype); + } +} + +/** + * Server user not found error + */ +export class ServerUserNotFoundError extends NotFoundError { + constructor(id?: string) { + super('ServerUser', id); + this.name = 'ServerUserNotFoundError'; + Object.setPrototypeOf(this, ServerUserNotFoundError.prototype); + } +} diff --git a/apps/server/src/test/fixtures.ts b/apps/server/src/test/fixtures.ts new file mode 100644 index 0000000..c2931e3 --- /dev/null +++ b/apps/server/src/test/fixtures.ts @@ -0,0 +1,298 @@ +/** + * Test fixtures and factory functions for creating test data + */ + +import type { + Session, + Rule, + User, + ServerUser, + Violation, + RuleType, + RuleParams, + SessionState, + MediaType, + ViolationSeverity, + ImpossibleTravelParams, + SimultaneousLocationsParams, + DeviceVelocityParams, + ConcurrentStreamsParams, + GeoRestrictionParams, + AuthUser, + UserRole, +} from '@tracearr/shared'; +import { RULE_DEFAULTS } from '@tracearr/shared'; +import { randomUUID } from 'node:crypto'; + +/** + * Create a mock session with sensible defaults + */ +export function createMockSession(overrides: Partial = {}): Session { + const id = overrides.id ?? randomUUID(); + const serverUserId = overrides.serverUserId ?? randomUUID(); + const serverId = overrides.serverId ?? randomUUID(); + + return { + id, + serverId, + serverUserId, + sessionKey: `session_${Date.now()}_${id.slice(0, 8)}`, + state: 'playing' as SessionState, + mediaType: 'movie' as MediaType, + mediaTitle: 'Test Movie', + grandparentTitle: null, + seasonNumber: null, + episodeNumber: null, + year: 2024, + thumbPath: null, + ratingKey: null, + externalSessionId: null, + startedAt: new Date(), + stoppedAt: null, + durationMs: null, + totalDurationMs: 7200000, + progressMs: 0, + // Pause tracking + lastPausedAt: null, + pausedDurationMs: 0, + referenceId: null, + watched: false, + // Network/device info + ipAddress: '192.168.1.1', + geoCity: 'New York', + geoRegion: 'New York', + geoCountry: 'US', + geoLat: 40.7128, + geoLon: -74.006, + playerName: 'Test Player', + deviceId: `device_${id.slice(0, 8)}`, + product: 'Plex Web', + device: 'Chrome', + platform: 'Windows', + quality: '1080p', + isTranscode: false, + bitrate: 10000, + ...overrides, + }; +} + +/** + * Create a mock rule with type-specific default params + */ +export function createMockRule( + type: T, + overrides: Partial = {} +): Rule { + return { + id: overrides.id ?? randomUUID(), + name: overrides.name ?? `Test ${type.replace(/_/g, ' ')} Rule`, + type, + params: overrides.params ?? JSON.parse(JSON.stringify(RULE_DEFAULTS[type])) as RuleParams, + serverUserId: overrides.serverUserId ?? null, // Global rule by default + isActive: overrides.isActive ?? true, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + }; +} + +/** + * Create a mock user (identity layer) + */ +export function createMockUser(overrides: Partial & { role?: UserRole } = {}): User { + const id = overrides.id ?? randomUUID(); + + return { + id, + username: overrides.username ?? `testuser_${id.slice(0, 8)}`, + name: overrides.name ?? null, + thumbnail: overrides.thumbnail ?? null, + email: overrides.email ?? null, + role: overrides.role ?? 'member', + aggregateTrustScore: overrides.aggregateTrustScore ?? 100, + totalViolations: overrides.totalViolations ?? 0, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + }; +} + +/** + * Create a mock server user (account on a specific media server) + */ +export function createMockServerUser(overrides: Partial = {}): ServerUser { + const id = overrides.id ?? randomUUID(); + + return { + id, + userId: overrides.userId ?? randomUUID(), + serverId: overrides.serverId ?? randomUUID(), + externalId: overrides.externalId ?? `ext_${id.slice(0, 8)}`, + username: overrides.username ?? `serveruser_${id.slice(0, 8)}`, + email: overrides.email ?? null, + thumbUrl: overrides.thumbUrl ?? null, + isServerAdmin: overrides.isServerAdmin ?? false, + trustScore: overrides.trustScore ?? 100, + sessionCount: overrides.sessionCount ?? 0, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + }; +} + +/** + * Create a mock violation + */ +export function createMockViolation( + overrides: Partial = {} +): Violation { + return { + id: overrides.id ?? randomUUID(), + ruleId: overrides.ruleId ?? randomUUID(), + serverUserId: overrides.serverUserId ?? randomUUID(), + sessionId: overrides.sessionId ?? randomUUID(), + severity: overrides.severity ?? ('warning' as ViolationSeverity), + data: overrides.data ?? {}, + createdAt: overrides.createdAt ?? new Date(), + acknowledgedAt: overrides.acknowledgedAt ?? null, + }; +} + +/** + * Create a mock auth user for request authentication + */ +export function createMockAuthUser(overrides: Partial = {}): AuthUser { + return { + userId: overrides.userId ?? randomUUID(), + username: overrides.username ?? 'testuser', + role: overrides.role ?? 'owner', + serverIds: overrides.serverIds ?? [randomUUID()], + }; +} + +/** + * Create impossible travel rule params + */ +export function createImpossibleTravelParams( + overrides: Partial = {} +): ImpossibleTravelParams { + return { + maxSpeedKmh: overrides.maxSpeedKmh ?? 500, + ignoreVpnRanges: overrides.ignoreVpnRanges ?? false, + }; +} + +/** + * Create simultaneous locations rule params + */ +export function createSimultaneousLocationsParams( + overrides: Partial = {} +): SimultaneousLocationsParams { + return { + minDistanceKm: overrides.minDistanceKm ?? 100, + }; +} + +/** + * Create device velocity rule params + */ +export function createDeviceVelocityParams( + overrides: Partial = {} +): DeviceVelocityParams { + return { + maxIps: overrides.maxIps ?? 5, + windowHours: overrides.windowHours ?? 24, + }; +} + +/** + * Create concurrent streams rule params + */ +export function createConcurrentStreamsParams( + overrides: Partial = {} +): ConcurrentStreamsParams { + return { + maxStreams: overrides.maxStreams ?? 3, + }; +} + +/** + * Create geo restriction rule params + */ +export function createGeoRestrictionParams( + overrides: Partial = {} +): GeoRestrictionParams { + return { + blockedCountries: overrides.blockedCountries ?? [], + }; +} + +/** + * Geographic coordinates for common test locations + */ +export const TEST_LOCATIONS = { + newYork: { lat: 40.7128, lon: -74.006, city: 'New York', region: 'New York', country: 'US' }, + losAngeles: { lat: 34.0522, lon: -118.2437, city: 'Los Angeles', region: 'California', country: 'US' }, + london: { lat: 51.5074, lon: -0.1278, city: 'London', region: 'England', country: 'GB' }, + tokyo: { lat: 35.6762, lon: 139.6503, city: 'Tokyo', region: 'Tokyo', country: 'JP' }, + sydney: { lat: -33.8688, lon: 151.2093, city: 'Sydney', region: 'New South Wales', country: 'AU' }, + paris: { lat: 48.8566, lon: 2.3522, city: 'Paris', region: 'Île-de-France', country: 'FR' }, + berlin: { lat: 52.52, lon: 13.405, city: 'Berlin', region: 'Berlin', country: 'DE' }, + moscow: { lat: 55.7558, lon: 37.6173, city: 'Moscow', region: 'Moscow', country: 'RU' }, +} as const; + +/** + * Calculate approximate distance between two locations in km + * (Haversine formula - same as RuleEngine) + */ +export function calculateDistanceKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // Earth's radius in km + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +/** + * Create a session that started N hours ago + */ +export function createSessionHoursAgo( + hoursAgo: number, + overrides: Partial = {} +): Session { + const startedAt = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); + return createMockSession({ startedAt, ...overrides }); +} + +/** + * Create multiple sessions for the same server user with different IPs + */ +export function createSessionsWithDifferentIps( + serverUserId: string, + count: number, + hoursSpread: number = 24 +): Session[] { + const sessions: Session[] = []; + const baseTime = Date.now(); + + for (let i = 0; i < count; i++) { + const hoursAgo = (hoursSpread / count) * i; + sessions.push( + createMockSession({ + serverUserId, + ipAddress: `192.168.1.${100 + i}`, + startedAt: new Date(baseTime - hoursAgo * 60 * 60 * 1000), + }) + ); + } + + return sessions; +} diff --git a/apps/server/src/test/helpers.ts b/apps/server/src/test/helpers.ts new file mode 100644 index 0000000..adea612 --- /dev/null +++ b/apps/server/src/test/helpers.ts @@ -0,0 +1,184 @@ +/** + * Test helper utilities for creating Fastify instances and mock data + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import jwt from '@fastify/jwt'; +import cookie from '@fastify/cookie'; +import sensible from '@fastify/sensible'; +import type { Redis } from 'ioredis'; +import type { AuthUser } from '@tracearr/shared'; + +// Mock Redis client for testing +export function createMockRedis() { + const store = new Map(); + + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }, + setex: async (key: string, _seconds: number, value: string) => { + store.set(key, value); + return 'OK'; + }, + del: async (key: string) => { + store.delete(key); + return 1; + }, + ping: async () => 'PONG', + keys: async (pattern: string) => { + const prefix = pattern.replace('*', ''); + return Array.from(store.keys()).filter(k => k.startsWith(prefix)); + }, + _store: store, // For test inspection + }; +} + +// Create a minimal test Fastify instance with auth +export async function createTestApp(): Promise { + const app = Fastify({ + logger: false, + }); + + // Register essential plugins + await app.register(sensible); + await app.register(cookie, { secret: 'test-cookie-secret' }); + await app.register(jwt, { + secret: process.env.JWT_SECRET ?? 'test-jwt-secret-must-be-32-chars-min', + sign: { algorithm: 'HS256' }, + }); + + // Add mock Redis (cast as unknown then to Redis to satisfy TypeScript for testing) + const mockRedis = createMockRedis(); + app.decorate('redis', mockRedis as unknown as Redis); + + // Add authenticate decorator (same as production) + app.decorate('authenticate', async function (request: any, reply: any) { + try { + await request.jwtVerify(); + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Add requireOwner decorator (same as production) + app.decorate('requireOwner', async function (request: any, reply: any) { + try { + await request.jwtVerify(); + if (request.user.role !== 'owner') { + reply.forbidden('Owner access required'); + } + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + return app; +} + +// Generate a valid test JWT token +export function generateTestToken( + app: FastifyInstance, + payload: AuthUser, + options?: { expiresIn?: string } +): string { + return app.jwt.sign(payload, { + expiresIn: options?.expiresIn ?? '1h', + }); +} + +// Create a test owner user payload +export function createOwnerPayload(overrides?: Partial): AuthUser { + return { + userId: 'owner-uuid-1234', + username: 'testowner', + role: 'owner', + serverIds: ['server-1', 'server-2'], + ...overrides, + }; +} + +// Create a test viewer user payload (for testing non-owner access) +export function createViewerPayload(overrides?: Partial): AuthUser { + return { + userId: 'viewer-uuid-5678', + username: 'testviewer', + role: 'viewer', + serverIds: [], + ...overrides, + }; +} + +// Create an expired token (already past expiration) +// We manually craft the token with an exp claim in the past since fast-jwt doesn't allow negative expiresIn +export function generateExpiredToken(app: FastifyInstance, payload: AuthUser): string { + // Create a token that expires in 1 second, then we'll manually modify the exp + const token = app.jwt.sign(payload, { expiresIn: '1s' }); + + // Decode and modify the exp to be in the past + const parts = token.split('.'); + const decodedPayload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()); + decodedPayload.exp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + decodedPayload.iat = Math.floor(Date.now() / 1000) - 7200; // 2 hours ago + + // Re-encode - signature will be invalid but that's fine for testing + const modifiedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString('base64url'); + return `${parts[0]}.${modifiedPayload}.${parts[2]}`; +} + +// Create a tampered token (valid format but modified payload) +export function generateTamperedToken(validToken: string): string { + const parts = validToken.split('.'); + if (parts.length !== 3) throw new Error('Invalid token format'); + + // Decode payload, modify it, re-encode without valid signature + const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString()); + payload.role = 'owner'; // Try to escalate privileges + const tamperedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + + // Return token with tampered payload but original signature (will fail verification) + return `${parts[0]}.${tamperedPayload}.${parts[2]}`; +} + +// Create a token signed with wrong secret +export function generateWrongSecretToken(payload: AuthUser): string { + const wrongApp = Fastify({ logger: false }); + wrongApp.register(jwt, { secret: 'wrong-secret-key-totally-different' }); + + // Can't use jwt until registered, so we'll manually create a fake token + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const fakeSignature = 'invalid_signature_here'; + + return `${header}.${payloadB64}.${fakeSignature}`; +} + +// Common injection payloads for security testing +export const INJECTION_PAYLOADS = { + sqlInjection: [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "admin'--", + "1; DELETE FROM sessions WHERE '1'='1", + "' UNION SELECT * FROM users--", + ], + xss: [ + '', + '">', + "javascript:alert('xss')", + '', + ], + commandInjection: [ + '; ls -la', + '| cat /etc/passwd', + '`whoami`', + '$(rm -rf /)', + ], + pathTraversal: [ + '../../../etc/passwd', + '..\\..\\..\\windows\\system32', + '%2e%2e%2f%2e%2e%2f', + ], +}; diff --git a/apps/server/src/test/integration-setup.ts b/apps/server/src/test/integration-setup.ts new file mode 100644 index 0000000..c45239b --- /dev/null +++ b/apps/server/src/test/integration-setup.ts @@ -0,0 +1,56 @@ +/** + * Integration Test Setup + * + * This setup file is used for integration tests that require a real database. + * Unlike unit tests, integration tests connect to an actual PostgreSQL/TimescaleDB instance. + * + * Requirements: + * - Docker compose dev services running: docker compose -f docker/docker-compose.dev.yml up -d + * - Database migrations applied: pnpm --filter @tracearr/server db:migrate + * + * The DATABASE_URL can be overridden via environment variable for CI. + */ + +import { beforeAll, afterAll, vi } from 'vitest'; + +// Set test environment variables BEFORE any imports that use them +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-must-be-32-chars-min'; +process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars!!!'; + +// Use real database - default to local dev, can be overridden by CI +process.env.DATABASE_URL = + process.env.DATABASE_URL || 'postgresql://tracearr:tracearr@localhost:5432/tracearr'; +process.env.REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'; + +// Silence console.log in tests unless DEBUG=true +if (!process.env.DEBUG) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'log').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'info').mockImplementation(() => {}); +} + +beforeAll(async () => { + process.env.TEST_INITIALIZED = 'true'; + + // Verify database connection + const { checkDatabaseConnection } = await import('../db/client.js'); + const connected = await checkDatabaseConnection(); + + if (!connected) { + throw new Error( + 'Integration tests require a running database.\n' + + 'Start dev services: docker compose -f docker/docker-compose.dev.yml up -d\n' + + 'Run migrations: pnpm --filter @tracearr/server db:migrate' + ); + } +}); + +afterAll(async () => { + delete process.env.TEST_INITIALIZED; + + // Close database connection pool + const { closeDatabase } = await import('../db/client.js'); + await closeDatabase(); +}); diff --git a/apps/server/src/test/schemas.test.ts b/apps/server/src/test/schemas.test.ts new file mode 100644 index 0000000..39358b6 --- /dev/null +++ b/apps/server/src/test/schemas.test.ts @@ -0,0 +1,585 @@ +/** + * Zod schema validation tests for rules and violations + * + * Tests validation behavior for: + * - Rule creation/update schemas + * - Rule parameter schemas for all 5 rule types + * - Violation query schema + */ + +import { describe, it, expect } from 'vitest'; +import { + createRuleSchema, + updateRuleSchema, + ruleIdParamSchema, + impossibleTravelParamsSchema, + simultaneousLocationsParamsSchema, + deviceVelocityParamsSchema, + concurrentStreamsParamsSchema, + geoRestrictionParamsSchema, + violationQuerySchema, + violationIdParamSchema, + terminateSessionBodySchema, +} from '@tracearr/shared'; +import { randomUUID } from 'node:crypto'; + +describe('Rule Schemas', () => { + describe('createRuleSchema', () => { + it('should validate a valid rule creation request', () => { + const input = { + name: 'Test Rule', + type: 'impossible_travel', + params: { maxSpeedKmh: 500 }, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Test Rule'); + expect(result.data.type).toBe('impossible_travel'); + expect(result.data.serverUserId).toBeNull(); // Default + expect(result.data.isActive).toBe(true); // Default + } + }); + + it('should apply default values', () => { + const input = { + name: 'Test Rule', + type: 'concurrent_streams', + params: { maxStreams: 3 }, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.serverUserId).toBeNull(); + expect(result.data.isActive).toBe(true); + } + }); + + it('should accept valid serverUserId', () => { + const serverUserId = randomUUID(); + const input = { + name: 'User Rule', + type: 'geo_restriction', + params: { blockedCountries: ['CN'] }, + serverUserId, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.serverUserId).toBe(serverUserId); + } + }); + + it('should accept null serverUserId for global rules', () => { + const input = { + name: 'Global Rule', + type: 'device_velocity', + params: { maxIps: 5, windowHours: 24 }, + serverUserId: null, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.serverUserId).toBeNull(); + } + }); + + it('should reject empty name', () => { + const input = { + name: '', + type: 'concurrent_streams', + params: {}, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should reject name over 100 characters', () => { + const input = { + name: 'a'.repeat(101), + type: 'concurrent_streams', + params: {}, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should reject invalid rule type', () => { + const input = { + name: 'Test Rule', + type: 'invalid_type', + params: {}, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should validate all 5 rule types', () => { + const types = [ + 'impossible_travel', + 'simultaneous_locations', + 'device_velocity', + 'concurrent_streams', + 'geo_restriction', + ]; + + for (const type of types) { + const input = { + name: `Test ${type}`, + type, + params: {}, + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(true); + } + }); + + it('should reject missing required fields', () => { + const inputs = [ + { type: 'concurrent_streams', params: {} }, // missing name + { name: 'Test', params: {} }, // missing type + { name: 'Test', type: 'concurrent_streams' }, // missing params + ]; + + for (const input of inputs) { + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(false); + } + }); + + it('should reject invalid serverUserId format', () => { + const input = { + name: 'Test Rule', + type: 'concurrent_streams', + params: {}, + serverUserId: 'not-a-uuid', + }; + + const result = createRuleSchema.safeParse(input); + expect(result.success).toBe(false); + }); + }); + + describe('updateRuleSchema', () => { + it('should validate partial updates', () => { + const input = { name: 'Updated Name' }; + const result = updateRuleSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it('should allow empty object (no updates)', () => { + const result = updateRuleSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('should validate isActive update', () => { + const result = updateRuleSchema.safeParse({ isActive: false }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isActive).toBe(false); + } + }); + + it('should validate params update', () => { + const result = updateRuleSchema.safeParse({ + params: { maxStreams: 5 }, + }); + expect(result.success).toBe(true); + }); + + it('should validate combined updates', () => { + const result = updateRuleSchema.safeParse({ + name: 'New Name', + params: { maxSpeedKmh: 1000 }, + isActive: false, + }); + expect(result.success).toBe(true); + }); + + it('should reject empty name', () => { + const result = updateRuleSchema.safeParse({ name: '' }); + expect(result.success).toBe(false); + }); + + it('should reject name over 100 characters', () => { + const result = updateRuleSchema.safeParse({ name: 'a'.repeat(101) }); + expect(result.success).toBe(false); + }); + }); + + describe('ruleIdParamSchema', () => { + it('should validate valid UUID', () => { + const result = ruleIdParamSchema.safeParse({ id: randomUUID() }); + expect(result.success).toBe(true); + }); + + it('should reject invalid UUID', () => { + const result = ruleIdParamSchema.safeParse({ id: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('should reject missing id', () => { + const result = ruleIdParamSchema.safeParse({}); + expect(result.success).toBe(false); + }); + }); +}); + +describe('Rule Parameter Schemas', () => { + describe('impossibleTravelParamsSchema', () => { + it('should validate with custom maxSpeedKmh', () => { + const result = impossibleTravelParamsSchema.safeParse({ + maxSpeedKmh: 1000, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxSpeedKmh).toBe(1000); + } + }); + + it('should apply default maxSpeedKmh', () => { + const result = impossibleTravelParamsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxSpeedKmh).toBe(500); + } + }); + + it('should accept ignoreVpnRanges', () => { + const result = impossibleTravelParamsSchema.safeParse({ + maxSpeedKmh: 500, + ignoreVpnRanges: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ignoreVpnRanges).toBe(true); + } + }); + + it('should reject non-positive maxSpeedKmh', () => { + const invalidValues = [0, -100]; + for (const val of invalidValues) { + const result = impossibleTravelParamsSchema.safeParse({ + maxSpeedKmh: val, + }); + expect(result.success).toBe(false); + } + }); + }); + + describe('simultaneousLocationsParamsSchema', () => { + it('should validate with custom minDistanceKm', () => { + const result = simultaneousLocationsParamsSchema.safeParse({ + minDistanceKm: 200, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.minDistanceKm).toBe(200); + } + }); + + it('should apply default minDistanceKm', () => { + const result = simultaneousLocationsParamsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.minDistanceKm).toBe(100); + } + }); + + it('should reject non-positive minDistanceKm', () => { + const result = simultaneousLocationsParamsSchema.safeParse({ + minDistanceKm: 0, + }); + expect(result.success).toBe(false); + }); + }); + + describe('deviceVelocityParamsSchema', () => { + it('should validate with custom values', () => { + const result = deviceVelocityParamsSchema.safeParse({ + maxIps: 10, + windowHours: 48, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxIps).toBe(10); + expect(result.data.windowHours).toBe(48); + } + }); + + it('should apply defaults', () => { + const result = deviceVelocityParamsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxIps).toBe(5); + expect(result.data.windowHours).toBe(24); + } + }); + + it('should reject non-positive maxIps', () => { + const result = deviceVelocityParamsSchema.safeParse({ + maxIps: 0, + windowHours: 24, + }); + expect(result.success).toBe(false); + }); + + it('should reject non-positive windowHours', () => { + const result = deviceVelocityParamsSchema.safeParse({ + maxIps: 5, + windowHours: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject non-integer values', () => { + const result = deviceVelocityParamsSchema.safeParse({ + maxIps: 5.5, + windowHours: 24.7, + }); + expect(result.success).toBe(false); + }); + }); + + describe('concurrentStreamsParamsSchema', () => { + it('should validate with custom maxStreams', () => { + const result = concurrentStreamsParamsSchema.safeParse({ + maxStreams: 5, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxStreams).toBe(5); + } + }); + + it('should apply default maxStreams', () => { + const result = concurrentStreamsParamsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxStreams).toBe(3); + } + }); + + it('should reject non-positive maxStreams', () => { + const result = concurrentStreamsParamsSchema.safeParse({ + maxStreams: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject non-integer maxStreams', () => { + const result = concurrentStreamsParamsSchema.safeParse({ + maxStreams: 3.5, + }); + expect(result.success).toBe(false); + }); + }); + + describe('geoRestrictionParamsSchema', () => { + it('should validate with blocked countries', () => { + const result = geoRestrictionParamsSchema.safeParse({ + blockedCountries: ['CN', 'RU', 'KP'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.blockedCountries).toEqual(['CN', 'RU', 'KP']); + } + }); + + it('should apply empty default for blockedCountries', () => { + const result = geoRestrictionParamsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.blockedCountries).toEqual([]); + } + }); + + it('should reject country codes that are not 2 characters', () => { + const invalidInputs = [ + { blockedCountries: ['USA'] }, // 3 chars + { blockedCountries: ['C'] }, // 1 char + { blockedCountries: ['CHINA'] }, // 5 chars + ]; + + for (const input of invalidInputs) { + const result = geoRestrictionParamsSchema.safeParse(input); + expect(result.success).toBe(false); + } + }); + + it('should allow empty array', () => { + const result = geoRestrictionParamsSchema.safeParse({ + blockedCountries: [], + }); + expect(result.success).toBe(true); + }); + }); +}); + +describe('Violation Schemas', () => { + describe('violationQuerySchema', () => { + it('should validate empty query (defaults)', () => { + const result = violationQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.pageSize).toBe(20); + } + }); + + it('should validate pagination params', () => { + const result = violationQuerySchema.safeParse({ + page: 5, + pageSize: 50, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(5); + expect(result.data.pageSize).toBe(50); + } + }); + + it('should coerce string numbers', () => { + const result = violationQuerySchema.safeParse({ + page: '3', + pageSize: '25', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(3); + expect(result.data.pageSize).toBe(25); + } + }); + + it('should validate serverUserId filter', () => { + const serverUserId = randomUUID(); + const result = violationQuerySchema.safeParse({ serverUserId }); + expect(result.success).toBe(true); + }); + + it('should validate ruleId filter', () => { + const ruleId = randomUUID(); + const result = violationQuerySchema.safeParse({ ruleId }); + expect(result.success).toBe(true); + }); + + it('should validate severity filter', () => { + const severities = ['low', 'warning', 'high']; + for (const severity of severities) { + const result = violationQuerySchema.safeParse({ severity }); + expect(result.success).toBe(true); + } + }); + + it('should reject invalid severity', () => { + const result = violationQuerySchema.safeParse({ + severity: 'critical', + }); + expect(result.success).toBe(false); + }); + + it('should validate acknowledged filter', () => { + const results = [ + violationQuerySchema.safeParse({ acknowledged: true }), + violationQuerySchema.safeParse({ acknowledged: false }), + violationQuerySchema.safeParse({ acknowledged: 'true' }), // coercion + violationQuerySchema.safeParse({ acknowledged: 'false' }), // coercion + ]; + + for (const result of results) { + expect(result.success).toBe(true); + } + }); + + it('should validate date filters', () => { + const result = violationQuerySchema.safeParse({ + startDate: '2024-01-01', + endDate: '2024-12-31', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startDate).toBeInstanceOf(Date); + expect(result.data.endDate).toBeInstanceOf(Date); + } + }); + + it('should reject pageSize over 100', () => { + const result = violationQuerySchema.safeParse({ + pageSize: 101, + }); + expect(result.success).toBe(false); + }); + + it('should reject non-positive page', () => { + const result = violationQuerySchema.safeParse({ + page: 0, + }); + expect(result.success).toBe(false); + }); + }); + + describe('violationIdParamSchema', () => { + it('should validate valid UUID', () => { + const result = violationIdParamSchema.safeParse({ id: randomUUID() }); + expect(result.success).toBe(true); + }); + + it('should reject invalid UUID', () => { + const result = violationIdParamSchema.safeParse({ id: 'invalid' }); + expect(result.success).toBe(false); + }); + }); +}); + +describe('Session Termination Schemas', () => { + describe('terminateSessionBodySchema', () => { + it('should validate empty body (no reason)', () => { + const result = terminateSessionBodySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.reason).toBeUndefined(); + } + }); + + it('should validate body with reason', () => { + const result = terminateSessionBodySchema.safeParse({ + reason: 'Concurrent stream limit exceeded', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.reason).toBe('Concurrent stream limit exceeded'); + } + }); + + it('should accept reason up to 500 characters', () => { + const longReason = 'a'.repeat(500); + const result = terminateSessionBodySchema.safeParse({ + reason: longReason, + }); + expect(result.success).toBe(true); + }); + + it('should reject reason over 500 characters', () => { + const tooLongReason = 'a'.repeat(501); + const result = terminateSessionBodySchema.safeParse({ + reason: tooLongReason, + }); + expect(result.success).toBe(false); + }); + + it('should accept undefined reason', () => { + const result = terminateSessionBodySchema.safeParse({ + reason: undefined, + }); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/apps/server/src/test/setup.integration.ts b/apps/server/src/test/setup.integration.ts new file mode 100644 index 0000000..06abf6c --- /dev/null +++ b/apps/server/src/test/setup.integration.ts @@ -0,0 +1,108 @@ +/** + * Vitest Integration Test Setup + * + * This setup file is used for integration tests that require a real database. + * It handles database initialization, migrations, TimescaleDB setup, cleanup, + * and provides proper test isolation. + * + * Uses the same database initialization as production server (index.ts): + * 1. Run Drizzle migrations + * 2. Initialize TimescaleDB (hypertables, compression, aggregates) + * + * Usage: vitest.integration.config.ts references this file. + * + * Requirements: + * - Test database running: docker compose -f docker/docker-compose.test.yml up -d + * - Migrations and TimescaleDB setup run automatically on first test run + */ + +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; +import { beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { installMatchers } from '@tracearr/test-utils/matchers'; +import { resetAllFactoryCounters } from '@tracearr/test-utils/factories'; +import { resetAllMocks } from '@tracearr/test-utils/mocks'; +import { + setupIntegrationTests, + resetDatabaseBeforeEach, +} from '@tracearr/test-utils/vitest.setup'; + +// Set test environment variables BEFORE any database imports +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-must-be-32-chars-min'; +process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars!!!'; +// Use port 5433 for test database (docker-compose.test.yml) to avoid conflicts with dev +process.env.DATABASE_URL = + process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/tracearr_test'; +// Use port 6380 for test Redis to avoid conflicts with dev +process.env.REDIS_URL = process.env.TEST_REDIS_URL || 'redis://localhost:6380'; + +// Install custom vitest matchers from test-utils +installMatchers(); + +// Silence console.log in tests unless DEBUG=true +if (!process.env.DEBUG) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'log').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'info').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +// Database cleanup function +let cleanup: (() => Promise) | null = null; + +// Get the migrations folder path (same as server index.ts uses) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const migrationsFolder = resolve(__dirname, '../db/migrations'); + +beforeAll(async () => { + process.env.TEST_INITIALIZED = 'true'; + + // Set up database connection + cleanup = await setupIntegrationTests(); + + // Run migrations (same as server startup) + const { runMigrations } = await import('../db/client.js'); + try { + await runMigrations(migrationsFolder); + } catch (error) { + // Migrations may have already been applied - that's OK + if (error instanceof Error && !error.message.includes('already exists')) { + console.error('[Test Setup] Migration error:', error.message); + throw error; + } + } + + // Initialize TimescaleDB (same as server startup) + // This sets up hypertables, compression, continuous aggregates, and indexes + const { initTimescaleDB } = await import('../db/timescale.js'); + try { + await initTimescaleDB(); + } catch (error) { + // TimescaleDB is optional - tests can still run without it + if (process.env.DEBUG) { + console.warn('[Test Setup] TimescaleDB init warning:', error); + } + } +}); + +// Reset database and factories before each test for isolation +beforeEach(async () => { + resetAllFactoryCounters(); + resetAllMocks(); + + // Reset database to clean state + await resetDatabaseBeforeEach(); +}); + +afterAll(async () => { + delete process.env.TEST_INITIALIZED; + + // Close database connection pool + if (cleanup) { + await cleanup(); + } +}); diff --git a/apps/server/src/test/setup.ts b/apps/server/src/test/setup.ts new file mode 100644 index 0000000..ee69dbb --- /dev/null +++ b/apps/server/src/test/setup.ts @@ -0,0 +1,49 @@ +/** + * Vitest test setup - runs before all tests + * + * This file sets up the test environment for unit/service/route tests. + * It installs custom matchers from @tracearr/test-utils and sets up + * environment variables. + * + * For integration tests that need database, use the integration config + * which has additional setup for database lifecycle. + */ + +import { beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { installMatchers } from '@tracearr/test-utils/matchers'; +import { resetAllFactoryCounters } from '@tracearr/test-utils/factories'; +import { resetAllMocks } from '@tracearr/test-utils/mocks'; + +// Set test environment variables +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-must-be-32-chars-min'; +process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars!!!'; +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/tracearr_test'; +process.env.REDIS_URL = 'redis://localhost:6379'; + +// Install custom vitest matchers from test-utils +installMatchers(); + +// Silence console.log in tests unless DEBUG=true +if (!process.env.DEBUG) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'log').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'info').mockImplementation(() => {}); +} + +beforeAll(() => { + // Global test setup + process.env.TEST_INITIALIZED = 'true'; +}); + +// Reset factories and mocks before each test for isolation +beforeEach(() => { + resetAllFactoryCounters(); + resetAllMocks(); +}); + +afterAll(() => { + // Global test cleanup + delete process.env.TEST_INITIALIZED; +}); diff --git a/apps/server/src/utils/__tests__/crypto.test.ts b/apps/server/src/utils/__tests__/crypto.test.ts new file mode 100644 index 0000000..938941f --- /dev/null +++ b/apps/server/src/utils/__tests__/crypto.test.ts @@ -0,0 +1,181 @@ +/** + * Crypto Utility Tests + * + * Tests the migration-focused crypto functions: + * - initializeEncryption: Initialize with env key (now optional, returns boolean) + * - isEncryptionInitialized: Check initialization state + * - looksEncrypted: Detect if a string might be encrypted + * - tryDecrypt: Attempt decryption, returning null on failure + * - migrateToken: Migrate encrypted tokens to plain text + * + * Note: Token encryption has been phased out. These functions now primarily + * support migrating existing encrypted tokens to plain text storage. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Valid 32-byte key as 64 hex characters +const VALID_KEY = 'a'.repeat(64); + +describe('crypto', () => { + const originalEnv = process.env.ENCRYPTION_KEY; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.ENCRYPTION_KEY = originalEnv; + } else { + delete process.env.ENCRYPTION_KEY; + } + }); + + describe('initializeEncryption', () => { + it('should return true with valid 64-char hex key', async () => { + process.env.ENCRYPTION_KEY = VALID_KEY; + + const { initializeEncryption: init, isEncryptionInitialized: isInit } = + await import('../crypto.js'); + + expect(init()).toBe(true); + expect(isInit()).toBe(true); + }); + + it('should return false when ENCRYPTION_KEY is missing', async () => { + delete process.env.ENCRYPTION_KEY; + + const { initializeEncryption: init, isEncryptionInitialized: isInit } = + await import('../crypto.js'); + + expect(init()).toBe(false); + expect(isInit()).toBe(false); + }); + + it('should return false when ENCRYPTION_KEY is empty string', async () => { + process.env.ENCRYPTION_KEY = ''; + + const { initializeEncryption: init } = await import('../crypto.js'); + + expect(init()).toBe(false); + }); + + it('should return false when key is wrong length', async () => { + process.env.ENCRYPTION_KEY = 'a'.repeat(32); // Too short + + const { initializeEncryption: init } = await import('../crypto.js'); + + expect(init()).toBe(false); + }); + }); + + describe('isEncryptionInitialized', () => { + it('should return false before initialization', async () => { + delete process.env.ENCRYPTION_KEY; + + const { isEncryptionInitialized: isInit } = await import('../crypto.js'); + + expect(isInit()).toBe(false); + }); + + it('should return true after successful initialization', async () => { + process.env.ENCRYPTION_KEY = VALID_KEY; + + const { initializeEncryption: init, isEncryptionInitialized: isInit } = + await import('../crypto.js'); + + init(); + expect(isInit()).toBe(true); + }); + }); + + describe('looksEncrypted', () => { + it('should return false for short strings', async () => { + const { looksEncrypted } = await import('../crypto.js'); + + expect(looksEncrypted('short')).toBe(false); + expect(looksEncrypted('abc123')).toBe(false); + expect(looksEncrypted('')).toBe(false); + }); + + it('should return false for non-base64 strings', async () => { + const { looksEncrypted } = await import('../crypto.js'); + + expect(looksEncrypted('this is not base64!!!')).toBe(false); + expect(looksEncrypted('hello world with spaces')).toBe(false); + }); + + it('should return false for typical Plex tokens', async () => { + const { looksEncrypted } = await import('../crypto.js'); + + // Plex tokens are typically 20-char alphanumeric + expect(looksEncrypted('abcdefghij1234567890')).toBe(false); + expect(looksEncrypted('PLEX_TOKEN_12345678')).toBe(false); + }); + + it('should return true for long base64 strings that could be encrypted', async () => { + const { looksEncrypted } = await import('../crypto.js'); + + // Minimum encrypted length is ~45 chars for IV + AuthTag + minimal ciphertext + const longBase64 = Buffer.from('x'.repeat(40)).toString('base64'); + expect(looksEncrypted(longBase64)).toBe(true); + }); + }); + + describe('tryDecrypt', () => { + it('should return null when encryption is not initialized', async () => { + delete process.env.ENCRYPTION_KEY; + + const { tryDecrypt } = await import('../crypto.js'); + + expect(tryDecrypt('somedata')).toBeNull(); + }); + + it('should return null for invalid encrypted data', async () => { + process.env.ENCRYPTION_KEY = VALID_KEY; + + const { initializeEncryption: init, tryDecrypt } = await import('../crypto.js'); + init(); + + expect(tryDecrypt('not-encrypted')).toBeNull(); + expect(tryDecrypt('invalid-base64!!!')).toBeNull(); + }); + + it('should return null for data encrypted with different key', async () => { + process.env.ENCRYPTION_KEY = VALID_KEY; + + const { initializeEncryption: init, tryDecrypt } = await import('../crypto.js'); + init(); + + // This is random base64 that won't decrypt with our key + const randomEncrypted = Buffer.from('x'.repeat(50)).toString('base64'); + expect(tryDecrypt(randomEncrypted)).toBeNull(); + }); + }); + + describe('migrateToken', () => { + it('should return plain text token unchanged if it does not look encrypted', async () => { + const { migrateToken } = await import('../crypto.js'); + + const plainToken = 'plex-token-12345'; + const result = migrateToken(plainToken); + + expect(result.plainText).toBe(plainToken); + expect(result.wasEncrypted).toBe(false); + }); + + it('should return token as-is if it looks encrypted but cannot be decrypted', async () => { + delete process.env.ENCRYPTION_KEY; // No key available + + const { migrateToken } = await import('../crypto.js'); + + // Long base64 string that looks encrypted + const fakeEncrypted = Buffer.from('x'.repeat(50)).toString('base64'); + const result = migrateToken(fakeEncrypted); + + expect(result.plainText).toBe(fakeEncrypted); + expect(result.wasEncrypted).toBe(false); + }); + }); +}); diff --git a/apps/server/src/utils/__tests__/errors.test.ts b/apps/server/src/utils/__tests__/errors.test.ts new file mode 100644 index 0000000..64d37d9 --- /dev/null +++ b/apps/server/src/utils/__tests__/errors.test.ts @@ -0,0 +1,656 @@ +/** + * Error Classes Tests + * + * Tests the ACTUAL exported error classes and functions from errors.ts: + * - AppError: Base error class + * - ValidationError: 400 Bad Request with field details + * - AuthenticationError: 401 Unauthorized + * - ForbiddenError: 403 Forbidden + * - NotFoundError: 404 Not Found + * - ConflictError: 409 Conflict + * - RateLimitError: 429 Too Many Requests + * - InternalError: 500 Internal Server Error + * - DatabaseError: 500 Database Error + * - ServiceUnavailableError: 503 Service Unavailable + * - ExternalServiceError: 502 External Service Error + * - ErrorCodes: Error code constants + * + * These tests validate: + * - Correct status codes + * - Correct error codes + * - toJSON() output format + * - ValidationError.fromZodError() conversion + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +// Import ACTUAL production classes and constants - not local duplicates +import { + AppError, + ValidationError, + AuthenticationError, + ForbiddenError, + NotFoundError, + ConflictError, + RateLimitError, + InternalError, + DatabaseError, + ServiceUnavailableError, + ExternalServiceError, + ErrorCodes, + registerErrorHandler, +} from '../errors.js'; + +describe('ErrorCodes', () => { + it('should have authentication error codes', () => { + expect(ErrorCodes.UNAUTHORIZED).toBe('AUTH_001'); + expect(ErrorCodes.INVALID_TOKEN).toBe('AUTH_002'); + expect(ErrorCodes.TOKEN_EXPIRED).toBe('AUTH_003'); + expect(ErrorCodes.INSUFFICIENT_PERMISSIONS).toBe('AUTH_004'); + }); + + it('should have validation error codes', () => { + expect(ErrorCodes.VALIDATION_ERROR).toBe('VAL_001'); + expect(ErrorCodes.INVALID_INPUT).toBe('VAL_002'); + expect(ErrorCodes.MISSING_FIELD).toBe('VAL_003'); + }); + + it('should have resource error codes', () => { + expect(ErrorCodes.NOT_FOUND).toBe('RES_001'); + expect(ErrorCodes.ALREADY_EXISTS).toBe('RES_002'); + expect(ErrorCodes.CONFLICT).toBe('RES_003'); + }); + + it('should have server error codes', () => { + expect(ErrorCodes.INTERNAL_ERROR).toBe('SRV_001'); + expect(ErrorCodes.SERVICE_UNAVAILABLE).toBe('SRV_002'); + expect(ErrorCodes.DATABASE_ERROR).toBe('SRV_003'); + expect(ErrorCodes.REDIS_ERROR).toBe('SRV_004'); + }); + + it('should have rate limiting error codes', () => { + expect(ErrorCodes.RATE_LIMITED).toBe('RATE_001'); + }); + + it('should have external service error codes', () => { + expect(ErrorCodes.PLEX_ERROR).toBe('EXT_001'); + expect(ErrorCodes.JELLYFIN_ERROR).toBe('EXT_002'); + expect(ErrorCodes.GEOIP_ERROR).toBe('EXT_003'); + }); +}); + +describe('AppError', () => { + it('should create error with correct properties', () => { + const error = new AppError('Test error', 400, ErrorCodes.VALIDATION_ERROR); + + expect(error.message).toBe('Test error'); + expect(error.statusCode).toBe(400); + expect(error.code).toBe('VAL_001'); + expect(error.isOperational).toBe(true); + expect(error.details).toBeUndefined(); + }); + + it('should include details when provided', () => { + const details = { field: 'username', reason: 'too short' }; + const error = new AppError('Validation failed', 400, ErrorCodes.VALIDATION_ERROR, details); + + expect(error.details).toEqual(details); + }); + + it('should be instance of Error', () => { + const error = new AppError('Test', 400, ErrorCodes.VALIDATION_ERROR); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(AppError); + }); + + it('should have proper stack trace', () => { + const error = new AppError('Test', 400, ErrorCodes.VALIDATION_ERROR); + + expect(error.stack).toBeDefined(); + // Stack should contain the test file path (where error was thrown) + expect(error.stack).toContain('errors.test.ts'); + }); + + describe('toJSON', () => { + it('should return ApiError format without details', () => { + const error = new AppError('Test error', 400, ErrorCodes.VALIDATION_ERROR); + const json = error.toJSON(); + + expect(json).toEqual({ + statusCode: 400, + error: 'Error', // Default Error name + message: 'Test error', + code: 'VAL_001', + }); + }); + + it('should include details in JSON when present', () => { + const details = { field: 'email' }; + const error = new AppError('Invalid', 400, ErrorCodes.VALIDATION_ERROR, details); + const json = error.toJSON(); + + expect(json.details).toEqual(details); + }); + }); +}); + +describe('ValidationError', () => { + it('should have correct status code and error code', () => { + const error = new ValidationError('Validation failed'); + + expect(error.statusCode).toBe(400); + expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR); + expect(error.name).toBe('ValidationError'); + }); + + it('should include field details when provided', () => { + const fields = [ + { field: 'email', message: 'Invalid email format' }, + { field: 'password', message: 'Too short' }, + ]; + const error = new ValidationError('Validation failed', fields); + + expect(error.fields).toEqual(fields); + expect(error.details).toEqual({ fields }); + }); + + it('should be instance of AppError', () => { + const error = new ValidationError('Test'); + + expect(error).toBeInstanceOf(AppError); + expect(error).toBeInstanceOf(ValidationError); + }); + + describe('fromZodError', () => { + it('should convert Zod error to ValidationError', () => { + const schema = z.object({ + email: z.email(), + age: z.number().min(18), + }); + + const result = schema.safeParse({ email: 'invalid', age: 10 }); + expect(result.success).toBe(false); + + if (!result.success) { + const error = ValidationError.fromZodError(result.error); + + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toBe('Validation failed'); + expect(error.fields).toHaveLength(2); + expect(error.fields).toContainEqual({ + field: 'email', + message: expect.any(String), + }); + expect(error.fields).toContainEqual({ + field: 'age', + message: expect.any(String), + }); + } + }); + + it('should handle nested path in Zod errors', () => { + const schema = z.object({ + user: z.object({ + profile: z.object({ + name: z.string().min(1), + }), + }), + }); + + const result = schema.safeParse({ user: { profile: { name: '' } } }); + expect(result.success).toBe(false); + + if (!result.success) { + const error = ValidationError.fromZodError(result.error); + + expect(error.fields?.[0]?.field).toBe('user.profile.name'); + } + }); + }); +}); + +describe('AuthenticationError', () => { + it('should have correct status code and error code', () => { + const error = new AuthenticationError(); + + expect(error.statusCode).toBe(401); + expect(error.code).toBe(ErrorCodes.UNAUTHORIZED); + expect(error.name).toBe('AuthenticationError'); + }); + + it('should use default message when none provided', () => { + const error = new AuthenticationError(); + + expect(error.message).toBe('Authentication required'); + }); + + it('should use custom message when provided', () => { + const error = new AuthenticationError('Invalid credentials'); + + expect(error.message).toBe('Invalid credentials'); + }); +}); + +describe('ForbiddenError', () => { + it('should have correct status code and error code', () => { + const error = new ForbiddenError(); + + expect(error.statusCode).toBe(403); + expect(error.code).toBe(ErrorCodes.INSUFFICIENT_PERMISSIONS); + expect(error.name).toBe('ForbiddenError'); + }); + + it('should use default message when none provided', () => { + const error = new ForbiddenError(); + + expect(error.message).toBe('Access denied'); + }); + + it('should use custom message when provided', () => { + const error = new ForbiddenError('Admin access required'); + + expect(error.message).toBe('Admin access required'); + }); +}); + +describe('NotFoundError', () => { + it('should have correct status code and error code', () => { + const error = new NotFoundError(); + + expect(error.statusCode).toBe(404); + expect(error.code).toBe(ErrorCodes.NOT_FOUND); + expect(error.name).toBe('NotFoundError'); + }); + + it('should use default resource name', () => { + const error = new NotFoundError(); + + expect(error.message).toBe('Resource not found'); + }); + + it('should include resource name in message', () => { + const error = new NotFoundError('User'); + + expect(error.message).toBe('User not found'); + }); + + it('should include resource ID when provided', () => { + const error = new NotFoundError('User', 'user-123'); + + expect(error.message).toBe("User with ID 'user-123' not found"); + }); +}); + +describe('ConflictError', () => { + it('should have correct status code and error code', () => { + const error = new ConflictError('Username already exists'); + + expect(error.statusCode).toBe(409); + expect(error.code).toBe(ErrorCodes.CONFLICT); + expect(error.name).toBe('ConflictError'); + expect(error.message).toBe('Username already exists'); + }); +}); + +describe('RateLimitError', () => { + it('should have correct status code and error code', () => { + const error = new RateLimitError(); + + expect(error.statusCode).toBe(429); + expect(error.code).toBe(ErrorCodes.RATE_LIMITED); + expect(error.name).toBe('RateLimitError'); + }); + + it('should use default message', () => { + const error = new RateLimitError(); + + expect(error.message).toBe('Too many requests'); + }); + + it('should include retryAfter when provided', () => { + const error = new RateLimitError('Rate limit exceeded', 60); + + expect(error.retryAfter).toBe(60); + expect(error.details).toEqual({ retryAfter: 60 }); + }); + + it('should not include retryAfter in details when not provided', () => { + const error = new RateLimitError('Rate limit exceeded'); + + expect(error.retryAfter).toBeUndefined(); + expect(error.details).toBeUndefined(); + }); +}); + +describe('InternalError', () => { + it('should have correct status code and error code', () => { + const error = new InternalError(); + + expect(error.statusCode).toBe(500); + expect(error.code).toBe(ErrorCodes.INTERNAL_ERROR); + expect(error.name).toBe('InternalError'); + }); + + it('should NOT be operational (requires investigation)', () => { + const error = new InternalError(); + + expect(error.isOperational).toBe(false); + }); + + it('should use default message', () => { + const error = new InternalError(); + + expect(error.message).toBe('An unexpected error occurred'); + }); + + it('should use custom message when provided', () => { + const error = new InternalError('Something went wrong'); + + expect(error.message).toBe('Something went wrong'); + }); +}); + +describe('DatabaseError', () => { + it('should have correct status code and error code', () => { + const error = new DatabaseError(); + + expect(error.statusCode).toBe(500); + expect(error.code).toBe(ErrorCodes.DATABASE_ERROR); + expect(error.name).toBe('DatabaseError'); + }); + + it('should use default message', () => { + const error = new DatabaseError(); + + expect(error.message).toBe('Database operation failed'); + }); + + it('should use custom message when provided', () => { + const error = new DatabaseError('Connection timeout'); + + expect(error.message).toBe('Connection timeout'); + }); +}); + +describe('ServiceUnavailableError', () => { + it('should have correct status code and error code', () => { + const error = new ServiceUnavailableError('Redis'); + + expect(error.statusCode).toBe(503); + expect(error.code).toBe(ErrorCodes.SERVICE_UNAVAILABLE); + expect(error.name).toBe('ServiceUnavailableError'); + }); + + it('should include service name in message', () => { + const error = new ServiceUnavailableError('Redis'); + + expect(error.message).toBe('Redis is currently unavailable'); + }); +}); + +describe('ExternalServiceError', () => { + it('should use PLEX_ERROR code for plex service', () => { + const error = new ExternalServiceError('plex', 'Connection refused'); + + expect(error.statusCode).toBe(502); + expect(error.code).toBe(ErrorCodes.PLEX_ERROR); + expect(error.name).toBe('ExternalServiceError'); + expect(error.message).toBe('Plex error: Connection refused'); + }); + + it('should use JELLYFIN_ERROR code for jellyfin service', () => { + const error = new ExternalServiceError('jellyfin', 'Invalid API key'); + + expect(error.code).toBe(ErrorCodes.JELLYFIN_ERROR); + expect(error.message).toBe('Jellyfin error: Invalid API key'); + }); + + it('should use GEOIP_ERROR code for geoip service', () => { + const error = new ExternalServiceError('geoip', 'Database not found'); + + expect(error.code).toBe(ErrorCodes.GEOIP_ERROR); + expect(error.message).toBe('Geoip error: Database not found'); + }); + + it('should use EMBY_ERROR code for emby service', () => { + const error = new ExternalServiceError('emby', 'Invalid API key'); + + expect(error.statusCode).toBe(502); + expect(error.code).toBe(ErrorCodes.EMBY_ERROR); + expect(error.name).toBe('ExternalServiceError'); + expect(error.message).toBe('Emby error: Invalid API key'); + }); + + it('should capitalize service name in message', () => { + const plexError = new ExternalServiceError('plex', 'test'); + const jellyfinError = new ExternalServiceError('jellyfin', 'test'); + const embyError = new ExternalServiceError('emby', 'test'); + const geoipError = new ExternalServiceError('geoip', 'test'); + + expect(plexError.message).toMatch(/^Plex error:/); + expect(jellyfinError.message).toMatch(/^Jellyfin error:/); + expect(embyError.message).toMatch(/^Emby error:/); + expect(geoipError.message).toMatch(/^Geoip error:/); + }); +}); + +describe('registerErrorHandler', () => { + it('should handle AppError correctly', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + app.get('/test', () => { + throw new ValidationError('Test validation error', [ + { field: 'name', message: 'Required' }, + ]); + }); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.code).toBe(ErrorCodes.VALIDATION_ERROR); + expect(body.message).toBe('Test validation error'); + + await app.close(); + }); + + it('should handle Fastify sensible errors', async () => { + const { default: Fastify } = await import('fastify'); + const { default: sensible } = await import('@fastify/sensible'); + const app = Fastify({ logger: false }); + + await app.register(sensible); + registerErrorHandler(app); + + app.get('/test', (_req, reply) => { + return reply.unauthorized('Custom unauthorized message'); + }); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.message).toBe('Custom unauthorized message'); + + await app.close(); + }); + + it('should handle generic errors with statusCode', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + app.get('/test', () => { + const err = new Error('Custom error') as Error & { statusCode: number }; + err.statusCode = 418; + throw err; + }); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.statusCode).toBe(418); + const body = response.json(); + expect(body.message).toBe('Custom error'); + + await app.close(); + }); + + it('should handle unknown errors as 500', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + app.get('/test', () => { + throw new Error('Unknown error'); + }); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.statusCode).toBe(500); + const body = response.json(); + expect(body.error).toBe('InternalServerError'); + // In non-production, shows actual message + expect(body.message).toBe('Unknown error'); + + await app.close(); + }); + + it('should handle 404 not found', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + const response = await app.inject({ method: 'GET', url: '/nonexistent' }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.error).toBe('NotFound'); + expect(body.message).toContain('not found'); + + await app.close(); + }); + + it('should handle Fastify validation errors', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + // Define a route with JSON schema validation + app.post( + '/test', + { + schema: { + body: { + type: 'object', + required: ['name', 'email'], + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }, + () => { + return { ok: true }; + } + ); + + // Send invalid body (missing required fields) + const response = await app.inject({ + method: 'POST', + url: '/test', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.message).toBe('Validation failed'); + + await app.close(); + }); + + it('should handle validation errors with missing message', async () => { + const { default: Fastify } = await import('fastify'); + const app = Fastify({ logger: false }); + + registerErrorHandler(app); + + // Manually simulate a validation error with missing message + app.get('/test', () => { + const err = new Error('Validation error') as Error & { + validation: Array<{ instancePath: string; message?: string }>; + }; + err.validation = [ + { instancePath: '/field1', message: undefined }, + { instancePath: '', message: 'Field is required' }, + ]; + throw err; + }); + + const response = await app.inject({ method: 'GET', url: '/test' }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.details?.fields).toContainEqual({ + field: '/field1', + message: 'Invalid value', + }); + expect(body.details?.fields).toContainEqual({ + field: 'unknown', + message: 'Field is required', + }); + + await app.close(); + }); +}); + +describe('Error hierarchy', () => { + it('all errors should be instances of Error', () => { + const errors = [ + new ValidationError('test'), + new AuthenticationError(), + new ForbiddenError(), + new NotFoundError(), + new ConflictError('test'), + new RateLimitError(), + new InternalError(), + new DatabaseError(), + new ServiceUnavailableError('test'), + new ExternalServiceError('plex', 'test'), + ]; + + for (const error of errors) { + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(AppError); + } + }); + + it('all errors should have isOperational true except InternalError', () => { + const operationalErrors = [ + new ValidationError('test'), + new AuthenticationError(), + new ForbiddenError(), + new NotFoundError(), + new ConflictError('test'), + new RateLimitError(), + new DatabaseError(), + new ServiceUnavailableError('test'), + new ExternalServiceError('plex', 'test'), + ]; + + for (const error of operationalErrors) { + expect(error.isOperational).toBe(true); + } + + expect(new InternalError().isOperational).toBe(false); + }); +}); diff --git a/apps/server/src/utils/__tests__/http.test.ts b/apps/server/src/utils/__tests__/http.test.ts new file mode 100644 index 0000000..c055d04 --- /dev/null +++ b/apps/server/src/utils/__tests__/http.test.ts @@ -0,0 +1,426 @@ +/** + * HTTP Client Utility Tests + * + * Tests the ACTUAL exported functions from http.ts: + * - HttpClientError: Error class with service context + * - fetchJson: Fetch and parse JSON response + * - fetchText: Fetch and return text response + * - fetchRaw: Fetch and return raw Response + * - fetchWithStatus: Fetch without throwing on non-2xx + * - Helper functions: jsonHeaders, plexHeaders, jellyfinHeaders + * + * These tests validate: + * - Successful requests return correct data + * - Non-2xx responses throw HttpClientError + * - Error contains correct service, status, url info + * - Timeout handling + * - Header helpers produce correct headers + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Import ACTUAL production functions - not local duplicates +import { + HttpClientError, + fetchJson, + fetchText, + fetchRaw, + fetchWithStatus, + jsonHeaders, + plexHeaders, + jellyfinHeaders, + embyHeaders, +} from '../http.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Helper to create mock Response +function createMockResponse(options: { + ok?: boolean; + status?: number; + statusText?: string; + body?: unknown; + headers?: Record; +}): Response { + const { + ok = true, + status = 200, + statusText = 'OK', + body = {}, + headers = {}, + } = options; + + return { + ok, + status, + statusText, + headers: new Headers(headers), + json: vi.fn().mockResolvedValue(body), + text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + clone: vi.fn(), + } as unknown as Response; +} + +describe('HttpClientError', () => { + it('should create error with all properties', () => { + const error = new HttpClientError({ + service: 'plex', + statusCode: 401, + statusText: 'Unauthorized', + url: 'https://plex.tv/api/test', + }); + + expect(error.name).toBe('HttpClientError'); + expect(error.service).toBe('plex'); + expect(error.statusCode).toBe(401); + expect(error.statusText).toBe('Unauthorized'); + expect(error.url).toBe('https://plex.tv/api/test'); + expect(error.message).toBe('plex request failed: 401 Unauthorized'); + }); + + it('should use custom message when provided', () => { + const error = new HttpClientError({ + service: 'jellyfin', + statusCode: 500, + statusText: 'Internal Server Error', + url: 'https://jellyfin.local/api', + message: 'Custom error message', + }); + + expect(error.message).toBe('Custom error message'); + }); + + it('should include response body when provided', () => { + const error = new HttpClientError({ + service: 'api', + statusCode: 400, + statusText: 'Bad Request', + url: 'https://api.example.com', + responseBody: '{"error": "Invalid input"}', + }); + + expect(error.responseBody).toBe('{"error": "Invalid input"}'); + }); + + it('should be instance of Error', () => { + const error = new HttpClientError({ + service: 'test', + statusCode: 404, + statusText: 'Not Found', + url: 'https://example.com', + }); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(HttpClientError); + }); + + describe('toExternalServiceError', () => { + it('should convert plex error to ExternalServiceError', () => { + const error = new HttpClientError({ + service: 'plex', + statusCode: 502, + statusText: 'Bad Gateway', + url: 'https://plex.tv/api', + }); + + const external = error.toExternalServiceError(); + + expect(external.name).toBe('ExternalServiceError'); + }); + + it('should convert jellyfin error to ExternalServiceError', () => { + const error = new HttpClientError({ + service: 'jellyfin', + statusCode: 503, + statusText: 'Service Unavailable', + url: 'https://jellyfin.local/api', + }); + + const external = error.toExternalServiceError(); + + expect(external.name).toBe('ExternalServiceError'); + }); + + it('should return self for unknown services', () => { + const error = new HttpClientError({ + service: 'unknown', + statusCode: 500, + statusText: 'Error', + url: 'https://example.com', + }); + + const result = error.toExternalServiceError(); + + expect(result).toBe(error); + }); + }); +}); + +describe('fetchJson', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('should fetch and parse JSON response', async () => { + const responseData = { id: 1, name: 'Test' }; + mockFetch.mockResolvedValue(createMockResponse({ body: responseData })); + + const result = await fetchJson('https://api.example.com/data'); + + expect(result).toEqual(responseData); + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {}); + }); + + it('should pass headers to fetch', async () => { + mockFetch.mockResolvedValue(createMockResponse({ body: {} })); + + await fetchJson('https://api.example.com/data', { + headers: { 'Authorization': 'Bearer token' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: { 'Authorization': 'Bearer token' }, + }) + ); + }); + + it('should throw HttpClientError on non-2xx response', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + ); + + await expect( + fetchJson('https://api.example.com/missing', { service: 'test' }) + ).rejects.toThrow(HttpClientError); + + try { + await fetchJson('https://api.example.com/missing', { service: 'test' }); + } catch (error) { + expect(error).toBeInstanceOf(HttpClientError); + expect((error as HttpClientError).statusCode).toBe(404); + expect((error as HttpClientError).service).toBe('test'); + } + }); + + it('should use default service name "API" when not provided', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }) + ); + + try { + await fetchJson('https://api.example.com/error'); + } catch (error) { + expect((error as HttpClientError).service).toBe('API'); + } + }); +}); + +describe('fetchText', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('should fetch and return text response', async () => { + const responseText = 'data'; + mockFetch.mockResolvedValue(createMockResponse({ body: responseText })); + + const result = await fetchText('https://api.example.com/xml'); + + expect(result).toBe(responseText); + }); + + it('should throw HttpClientError on non-2xx response', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 403, + statusText: 'Forbidden', + }) + ); + + await expect( + fetchText('https://api.example.com/forbidden', { service: 'plex' }) + ).rejects.toThrow(HttpClientError); + }); +}); + +describe('fetchRaw', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('should return raw Response object', async () => { + const mockResponse = createMockResponse({ body: 'binary data' }); + mockFetch.mockResolvedValue(mockResponse); + + const result = await fetchRaw('https://api.example.com/image'); + + expect(result).toBe(mockResponse); + }); + + it('should throw HttpClientError on non-2xx response', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + ); + + await expect(fetchRaw('https://api.example.com/missing')).rejects.toThrow( + HttpClientError + ); + }); +}); + +describe('fetchWithStatus', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('should return status info for successful request', async () => { + const responseData = { id: 1 }; + mockFetch.mockResolvedValue( + createMockResponse({ + ok: true, + status: 200, + statusText: 'OK', + body: responseData, + }) + ); + + const result = await fetchWithStatus('https://api.example.com/data'); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.statusText).toBe('OK'); + expect(result.data).toEqual(responseData); + }); + + it('should NOT throw on non-2xx response', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + }) + ); + + const result = await fetchWithStatus('https://api.example.com/missing'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + expect(result.data).toBeNull(); + }); + + it('should return null data when response is not JSON', async () => { + const mockResponse = createMockResponse({ + ok: true, + status: 200, + statusText: 'OK', + }); + (mockResponse.json as ReturnType).mockRejectedValue(new Error('Not JSON')); + mockFetch.mockResolvedValue(mockResponse); + + const result = await fetchWithStatus('https://api.example.com/text'); + + expect(result.ok).toBe(true); + expect(result.data).toBeNull(); + }); + + it('should include headers in response', async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + ok: true, + status: 200, + statusText: 'OK', + headers: { 'X-Custom-Header': 'value' }, + }) + ); + + const result = await fetchWithStatus('https://api.example.com/data'); + + expect(result.headers).toBeInstanceOf(Headers); + }); +}); + +describe('Header helpers', () => { + describe('jsonHeaders', () => { + it('should return JSON headers without token', () => { + const headers = jsonHeaders(); + + expect(headers['Accept']).toBe('application/json'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['Authorization']).toBeUndefined(); + }); + + it('should include Bearer token when provided', () => { + const headers = jsonHeaders('my-token'); + + expect(headers['Authorization']).toBe('Bearer my-token'); + }); + }); + + describe('plexHeaders', () => { + it('should return Plex-specific headers without token', () => { + const headers = plexHeaders(); + + expect(headers['Accept']).toBe('application/json'); + expect(headers['X-Plex-Client-Identifier']).toBe('tracearr'); + expect(headers['X-Plex-Product']).toBe('Tracearr'); + expect(headers['X-Plex-Version']).toBe('1.0.0'); + expect(headers['X-Plex-Device']).toBe('Server'); + expect(headers['X-Plex-Platform']).toBe('Node.js'); + expect(headers['X-Plex-Token']).toBeUndefined(); + }); + + it('should include X-Plex-Token when provided', () => { + const headers = plexHeaders('plex-auth-token'); + + expect(headers['X-Plex-Token']).toBe('plex-auth-token'); + }); + }); + + describe('jellyfinHeaders', () => { + it('should return Jellyfin headers without API key', () => { + const headers = jellyfinHeaders(); + + expect(headers['Accept']).toBe('application/json'); + expect(headers['X-Emby-Token']).toBeUndefined(); + }); + + it('should include X-Emby-Token when API key provided', () => { + const headers = jellyfinHeaders('jellyfin-api-key'); + + expect(headers['X-Emby-Token']).toBe('jellyfin-api-key'); + }); + }); + + describe('embyHeaders', () => { + it('should return Emby headers without API key', () => { + const headers = embyHeaders(); + + expect(headers['Accept']).toBe('application/json'); + expect(headers['X-Emby-Token']).toBeUndefined(); + }); + + it('should include X-Emby-Token when API key provided', () => { + const headers = embyHeaders('emby-api-key'); + + expect(headers['X-Emby-Token']).toBe('emby-api-key'); + }); + }); +}); diff --git a/apps/server/src/utils/__tests__/jwt.test.ts b/apps/server/src/utils/__tests__/jwt.test.ts new file mode 100644 index 0000000..2d684ab --- /dev/null +++ b/apps/server/src/utils/__tests__/jwt.test.ts @@ -0,0 +1,315 @@ +/** + * JWT Utility Tests + * + * Tests the ACTUAL exported functions from jwt.ts: + * - verifyJwt: Verify JWT tokens and extract user payload + * + * These tests validate: + * - Valid token verification + * - Expired token handling + * - Invalid signature detection + * - Malformed payload handling + * - Missing JWT_SECRET handling + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import jwt from 'jsonwebtoken'; + +// Import ACTUAL production functions and types - not local duplicates +import { verifyJwt, type JwtVerifyResult, type JwtVerifyError } from '../jwt.js'; + +const TEST_SECRET = 'test-jwt-secret-for-testing-only'; +const DIFFERENT_SECRET = 'different-secret-that-wont-work'; + +// Valid user payload matching AuthUser interface +const VALID_USER_PAYLOAD = { + userId: 'user-123', + username: 'testuser', + role: 'admin' as const, + serverIds: ['server-1', 'server-2'], +}; + +describe('jwt', () => { + const originalSecret = process.env.JWT_SECRET; + + beforeEach(() => { + process.env.JWT_SECRET = TEST_SECRET; + }); + + afterEach(() => { + if (originalSecret !== undefined) { + process.env.JWT_SECRET = originalSecret; + } else { + delete process.env.JWT_SECRET; + } + }); + + describe('verifyJwt', () => { + describe('valid tokens', () => { + it('should verify a valid token and return user data', () => { + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(true); + const successResult = result as JwtVerifyResult; + expect(successResult.user.userId).toBe('user-123'); + expect(successResult.user.username).toBe('testuser'); + expect(successResult.user.role).toBe('admin'); + expect(successResult.user.serverIds).toEqual(['server-1', 'server-2']); + }); + + it('should handle token without serverIds (defaults to empty array)', () => { + const payloadWithoutServerIds = { + userId: 'user-456', + username: 'anotheruser', + role: 'user' as const, + // No serverIds + }; + + const token = jwt.sign(payloadWithoutServerIds, TEST_SECRET, { + algorithm: 'HS256', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(true); + const successResult = result as JwtVerifyResult; + expect(successResult.user.serverIds).toEqual([]); + }); + + it('should verify token with viewer role', () => { + const viewerPayload = { + userId: 'user-789', + username: 'viewer', + role: 'viewer' as const, + serverIds: [], + }; + + const token = jwt.sign(viewerPayload, TEST_SECRET, { algorithm: 'HS256' }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(true); + const successResult = result as JwtVerifyResult; + expect(successResult.user.role).toBe('viewer'); + }); + }); + + describe('expired tokens', () => { + it('should return error for expired token', () => { + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + expiresIn: '-1s', // Already expired + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Token expired'); + }); + + it('should return error for token that expired long ago', () => { + // Create a token that expired an hour ago + const token = jwt.sign( + { ...VALID_USER_PAYLOAD, iat: Math.floor(Date.now() / 1000) - 7200 }, + TEST_SECRET, + { algorithm: 'HS256', expiresIn: '1h' } + ); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Token expired'); + }); + }); + + describe('invalid tokens', () => { + it('should return error for token signed with wrong secret', () => { + const token = jwt.sign(VALID_USER_PAYLOAD, DIFFERENT_SECRET, { + algorithm: 'HS256', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token'); + }); + + it('should return error for malformed token', () => { + const result = verifyJwt('not.a.valid.jwt.token'); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token'); + }); + + it('should return error for empty string token', () => { + const result = verifyJwt(''); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token'); + }); + + it('should return error for token with wrong algorithm', () => { + // Sign with HS384 but verifyJwt only accepts HS256 + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS384', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token'); + }); + + it('should return error for tampered token', () => { + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + }); + + // Tamper with the payload part + const parts = token.split('.'); + parts[1] = Buffer.from(JSON.stringify({ ...VALID_USER_PAYLOAD, role: 'admin' })) + .toString('base64') + .replace(/=/g, ''); + const tamperedToken = parts.join('.'); + + const result = verifyJwt(tamperedToken); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token'); + }); + }); + + describe('invalid payload', () => { + it('should return error when userId is missing', () => { + const invalidPayload = { + username: 'testuser', + role: 'admin', + }; + + const token = jwt.sign(invalidPayload, TEST_SECRET, { algorithm: 'HS256' }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token payload'); + }); + + it('should return error when username is missing', () => { + const invalidPayload = { + userId: 'user-123', + role: 'admin', + }; + + const token = jwt.sign(invalidPayload, TEST_SECRET, { algorithm: 'HS256' }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token payload'); + }); + + it('should return error when role is missing', () => { + const invalidPayload = { + userId: 'user-123', + username: 'testuser', + }; + + const token = jwt.sign(invalidPayload, TEST_SECRET, { algorithm: 'HS256' }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token payload'); + }); + + it('should return error for completely empty payload', () => { + const token = jwt.sign({}, TEST_SECRET, { algorithm: 'HS256' }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('Invalid token payload'); + }); + }); + + describe('missing JWT_SECRET', () => { + it('should return error when JWT_SECRET is not configured', () => { + delete process.env.JWT_SECRET; + + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('JWT_SECRET not configured'); + }); + + it('should return error when JWT_SECRET is empty string', () => { + process.env.JWT_SECRET = ''; + + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + }); + + const result = verifyJwt(token); + + expect(result.valid).toBe(false); + const errorResult = result as JwtVerifyError; + expect(errorResult.error).toBe('JWT_SECRET not configured'); + }); + }); + + describe('real-world scenarios', () => { + it('should work with WebSocket authentication flow', () => { + // Simulate: Client sends token in connection params + const token = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + expiresIn: '7d', + }); + + // Server verifies token + const result = verifyJwt(token); + + expect(result.valid).toBe(true); + const successResult = result as JwtVerifyResult; + + // Server can now use user data for authorization + expect(successResult.user.userId).toBe('user-123'); + expect(successResult.user.serverIds).toContain('server-1'); + }); + + it('should handle token refresh scenario', () => { + // Original token about to expire + const oldToken = jwt.sign(VALID_USER_PAYLOAD, TEST_SECRET, { + algorithm: 'HS256', + expiresIn: '1m', + }); + + const result = verifyJwt(oldToken); + expect(result.valid).toBe(true); + + // In a real scenario, server would issue new token + // This test just verifies the old token is still valid + }); + }); + }); +}); diff --git a/apps/server/src/utils/__tests__/parsing.test.ts b/apps/server/src/utils/__tests__/parsing.test.ts new file mode 100644 index 0000000..28abb1b --- /dev/null +++ b/apps/server/src/utils/__tests__/parsing.test.ts @@ -0,0 +1,564 @@ +/** + * Parsing Utility Tests + * + * Tests the ACTUAL exported functions from parsing.ts: + * - parseString / parseOptionalString / parseStringOrNull + * - parseNumber / parseOptionalNumber / parseNumberOrEmpty + * - parseBoolean / parseOptionalBoolean + * - parseArray / parseFilteredArray / parseFirstArrayElement + * - getNestedObject / getNestedValue + * - parseDate / parseDateString + * - parse (convenience object) + * + * These tests validate: + * - Correct type coercion for various inputs + * - Default value handling + * - null/undefined handling + * - Edge cases (empty strings, NaN, invalid dates) + * - Real-world API response patterns from Plex/Jellyfin/Tautulli + */ + +import { describe, it, expect } from 'vitest'; + +// Import ACTUAL production functions - not local duplicates +import { + parseString, + parseOptionalString, + parseStringOrNull, + parseNumber, + parseOptionalNumber, + parseNumberOrEmpty, + parseBoolean, + parseOptionalBoolean, + parseArray, + parseFilteredArray, + parseFirstArrayElement, + getNestedObject, + getNestedValue, + parseDate, + parseDateString, + parse, +} from '../parsing.js'; + +describe('parseString', () => { + it('should convert values to string', () => { + expect(parseString('hello')).toBe('hello'); + expect(parseString(123)).toBe('123'); + expect(parseString(true)).toBe('true'); + expect(parseString(0)).toBe('0'); + }); + + it('should return default for null/undefined', () => { + expect(parseString(null)).toBe(''); + expect(parseString(undefined)).toBe(''); + }); + + it('should use custom default value', () => { + expect(parseString(null, 'default')).toBe('default'); + expect(parseString(undefined, 'unknown')).toBe('unknown'); + }); + + it('should handle empty string as valid value', () => { + expect(parseString('')).toBe(''); + }); + + it('should handle objects (converts to [object Object])', () => { + expect(parseString({})).toBe('[object Object]'); + }); +}); + +describe('parseOptionalString', () => { + it('should convert values to string', () => { + expect(parseOptionalString('hello')).toBe('hello'); + expect(parseOptionalString(123)).toBe('123'); + }); + + it('should return undefined for null/undefined', () => { + expect(parseOptionalString(null)).toBeUndefined(); + expect(parseOptionalString(undefined)).toBeUndefined(); + }); + + it('should handle empty string as valid value', () => { + expect(parseOptionalString('')).toBe(''); + }); +}); + +describe('parseStringOrNull', () => { + it('should convert values to string', () => { + expect(parseStringOrNull('hello')).toBe('hello'); + expect(parseStringOrNull(123)).toBe('123'); + }); + + it('should return null for null/undefined', () => { + expect(parseStringOrNull(null)).toBeNull(); + expect(parseStringOrNull(undefined)).toBeNull(); + }); + + it('should handle empty string as valid value', () => { + expect(parseStringOrNull('')).toBe(''); + }); +}); + +describe('parseNumber', () => { + it('should convert numeric values', () => { + expect(parseNumber(123)).toBe(123); + expect(parseNumber('456')).toBe(456); + expect(parseNumber(3.14)).toBe(3.14); + expect(parseNumber('3.14')).toBe(3.14); + }); + + it('should return default for null/undefined', () => { + expect(parseNumber(null)).toBe(0); + expect(parseNumber(undefined)).toBe(0); + }); + + it('should return default for NaN', () => { + expect(parseNumber('invalid')).toBe(0); + expect(parseNumber(NaN)).toBe(0); + }); + + it('should use custom default value', () => { + expect(parseNumber(null, -1)).toBe(-1); + expect(parseNumber('invalid', 100)).toBe(100); + }); + + it('should handle zero as valid value', () => { + expect(parseNumber(0)).toBe(0); + expect(parseNumber('0')).toBe(0); + }); + + it('should handle negative numbers', () => { + expect(parseNumber(-5)).toBe(-5); + expect(parseNumber('-10')).toBe(-10); + }); +}); + +describe('parseOptionalNumber', () => { + it('should convert numeric values', () => { + expect(parseOptionalNumber(123)).toBe(123); + expect(parseOptionalNumber('456')).toBe(456); + }); + + it('should return undefined for null/undefined', () => { + expect(parseOptionalNumber(null)).toBeUndefined(); + expect(parseOptionalNumber(undefined)).toBeUndefined(); + }); + + it('should return undefined for NaN', () => { + expect(parseOptionalNumber('invalid')).toBeUndefined(); + expect(parseOptionalNumber(NaN)).toBeUndefined(); + }); + + it('should handle zero as valid value', () => { + expect(parseOptionalNumber(0)).toBe(0); + }); +}); + +describe('parseNumberOrEmpty', () => { + it('should convert numeric values', () => { + expect(parseNumberOrEmpty(123)).toBe(123); + expect(parseNumberOrEmpty('456')).toBe(456); + }); + + it('should return null for empty string (Tautulli pattern)', () => { + expect(parseNumberOrEmpty('')).toBeNull(); + }); + + it('should return null for null/undefined', () => { + expect(parseNumberOrEmpty(null)).toBeNull(); + expect(parseNumberOrEmpty(undefined)).toBeNull(); + }); + + it('should return null for invalid numbers', () => { + expect(parseNumberOrEmpty('invalid')).toBeNull(); + expect(parseNumberOrEmpty(NaN)).toBeNull(); + }); + + it('should handle zero as valid value', () => { + expect(parseNumberOrEmpty(0)).toBe(0); + expect(parseNumberOrEmpty('0')).toBe(0); + }); + + // Real Tautulli API patterns + it('should handle Tautulli year field (number for movies, "" for episodes)', () => { + expect(parseNumberOrEmpty(2024)).toBe(2024); // Movie + expect(parseNumberOrEmpty('')).toBeNull(); // Episode + }); + + it('should handle Tautulli media_index field', () => { + expect(parseNumberOrEmpty(5)).toBe(5); // Episode number + expect(parseNumberOrEmpty('')).toBeNull(); // Movie + }); +}); + +describe('parseBoolean', () => { + it('should convert truthy values', () => { + expect(parseBoolean(true)).toBe(true); + expect(parseBoolean(1)).toBe(true); + expect(parseBoolean('true')).toBe(true); + expect(parseBoolean('anything')).toBe(true); + }); + + it('should convert falsy values', () => { + expect(parseBoolean(false)).toBe(false); + expect(parseBoolean(0)).toBe(false); + expect(parseBoolean('')).toBe(false); + }); + + it('should return default for null/undefined', () => { + expect(parseBoolean(null)).toBe(false); + expect(parseBoolean(undefined)).toBe(false); + }); + + it('should use custom default value', () => { + expect(parseBoolean(null, true)).toBe(true); + expect(parseBoolean(undefined, true)).toBe(true); + }); +}); + +describe('parseOptionalBoolean', () => { + it('should convert values to boolean', () => { + expect(parseOptionalBoolean(true)).toBe(true); + expect(parseOptionalBoolean(false)).toBe(false); + expect(parseOptionalBoolean(1)).toBe(true); + expect(parseOptionalBoolean(0)).toBe(false); + }); + + it('should return undefined for null/undefined', () => { + expect(parseOptionalBoolean(null)).toBeUndefined(); + expect(parseOptionalBoolean(undefined)).toBeUndefined(); + }); +}); + +describe('parseArray', () => { + it('should map array elements', () => { + const input = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const result = parseArray(input, (item) => (item as { id: number }).id); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should return empty array for non-array', () => { + expect(parseArray(null, (x) => x)).toEqual([]); + expect(parseArray(undefined, (x) => x)).toEqual([]); + expect(parseArray('string', (x) => x)).toEqual([]); + expect(parseArray({}, (x) => x)).toEqual([]); + }); + + it('should handle empty array', () => { + expect(parseArray([], (x) => x)).toEqual([]); + }); + + it('should pass index to mapper', () => { + const input = ['a', 'b', 'c']; + const result = parseArray(input, (item, index) => `${item}-${index}`); + + expect(result).toEqual(['a-0', 'b-1', 'c-2']); + }); +}); + +describe('parseFilteredArray', () => { + it('should filter and map array elements', () => { + const input = [ + { id: 1, active: true }, + { id: 2, active: false }, + { id: 3, active: true }, + ]; + const result = parseFilteredArray( + input, + (item) => (item as { active: boolean }).active, + (item) => (item as { id: number }).id + ); + + expect(result).toEqual([1, 3]); + }); + + it('should return empty array for non-array', () => { + expect(parseFilteredArray(null, () => true, (x) => x)).toEqual([]); + }); + + // Real Jellyfin pattern: filter sessions with NowPlayingItem + it('should handle Jellyfin session filtering pattern', () => { + const sessions = [ + { Id: '1', NowPlayingItem: { Name: 'Movie' } }, + { Id: '2', NowPlayingItem: null }, + { Id: '3', NowPlayingItem: { Name: 'Show' } }, + ]; + + const result = parseFilteredArray( + sessions, + (s) => (s as { NowPlayingItem: unknown }).NowPlayingItem != null, + (s) => (s as { Id: string }).Id + ); + + expect(result).toEqual(['1', '3']); + }); +}); + +describe('parseFirstArrayElement', () => { + it('should get property from first array element', () => { + const media = [{ bitrate: 8000000 }, { bitrate: 4000000 }]; + expect(parseFirstArrayElement(media, 'bitrate')).toBe(8000000); + }); + + it('should return default for empty array', () => { + expect(parseFirstArrayElement([], 'bitrate', 0)).toBe(0); + expect(parseFirstArrayElement([], 'bitrate')).toBeUndefined(); + }); + + it('should return default for non-array', () => { + expect(parseFirstArrayElement(null, 'bitrate', 0)).toBe(0); + expect(parseFirstArrayElement(undefined, 'bitrate', 0)).toBe(0); + expect(parseFirstArrayElement('string', 'bitrate', 0)).toBe(0); + }); + + it('should return default when property not found', () => { + const media = [{ other: 'value' }]; + expect(parseFirstArrayElement(media, 'bitrate', 0)).toBe(0); + }); + + it('should return undefined when property is undefined', () => { + const media = [{ bitrate: undefined }]; + expect(parseFirstArrayElement(media, 'bitrate', 0)).toBe(0); + }); + + it('should return default when first element is null', () => { + const media = [null, { bitrate: 8000000 }]; + expect(parseFirstArrayElement(media, 'bitrate', 0)).toBe(0); + }); + + it('should return default when first element is not an object', () => { + const media = ['string', { bitrate: 8000000 }]; + expect(parseFirstArrayElement(media, 'bitrate', 0)).toBe(0); + }); + + // Real Plex pattern: (item.Media as Record[])?.[0]?.bitrate + it('should handle Plex Media array pattern', () => { + const item = { + Media: [{ bitrate: 10000000, videoResolution: '1080' }], + }; + expect(parseFirstArrayElement(item.Media, 'bitrate')).toBe(10000000); + expect(parseFirstArrayElement(item.Media, 'videoResolution')).toBe('1080'); + }); + + // Real Jellyfin pattern: mediaSources?.[0]?.bitrate + it('should handle Jellyfin MediaSources pattern', () => { + const nowPlaying = { + mediaSources: [{ Bitrate: 5000000 }], + }; + expect(parseFirstArrayElement(nowPlaying.mediaSources, 'Bitrate')).toBe(5000000); + }); +}); + +describe('getNestedObject', () => { + it('should get nested object', () => { + const user = { Policy: { IsAdministrator: true } }; + const policy = getNestedObject(user, 'Policy'); + + expect(policy).toEqual({ IsAdministrator: true }); + }); + + it('should return undefined for missing key', () => { + const user = { Name: 'Test' }; + expect(getNestedObject(user, 'Policy')).toBeUndefined(); + }); + + it('should return undefined for null/undefined input', () => { + expect(getNestedObject(null, 'Policy')).toBeUndefined(); + expect(getNestedObject(undefined, 'Policy')).toBeUndefined(); + }); + + it('should return undefined for non-object value', () => { + const user = { Policy: 'string' }; + expect(getNestedObject(user, 'Policy')).toBeUndefined(); + }); +}); + +describe('getNestedValue', () => { + it('should get deeply nested value', () => { + const data = { a: { b: { c: 'value' } } }; + expect(getNestedValue(data, 'a', 'b', 'c')).toBe('value'); + }); + + it('should return undefined for missing path', () => { + const data = { a: { b: 1 } }; + expect(getNestedValue(data, 'a', 'b', 'c')).toBeUndefined(); + expect(getNestedValue(data, 'x')).toBeUndefined(); + }); + + it('should return undefined for null in path', () => { + const data = { a: null }; + expect(getNestedValue(data, 'a', 'b')).toBeUndefined(); + }); + + // Real Jellyfin pattern: (session.PlayState as Record).PositionTicks + it('should handle Jellyfin PlayState.PositionTicks pattern', () => { + const session = { + PlayState: { PositionTicks: 1234567890, IsPaused: false }, + }; + expect(getNestedValue(session, 'PlayState', 'PositionTicks')).toBe(1234567890); + expect(getNestedValue(session, 'PlayState', 'IsPaused')).toBe(false); + }); + + // Real Jellyfin pattern: (user.Policy as Record)?.IsAdministrator + it('should handle Jellyfin user.Policy.IsAdministrator pattern', () => { + const user = { + Policy: { IsAdministrator: true, IsDisabled: false }, + }; + expect(getNestedValue(user, 'Policy', 'IsAdministrator')).toBe(true); + expect(getNestedValue(user, 'Policy', 'IsDisabled')).toBe(false); + }); +}); + +describe('parseDate', () => { + it('should parse valid ISO date string', () => { + const date = parseDate('2024-01-15T10:30:00.000Z'); + expect(date).toBeInstanceOf(Date); + expect(date?.toISOString()).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should parse various date formats', () => { + expect(parseDate('2024-01-15')).toBeInstanceOf(Date); + expect(parseDate('January 15, 2024')).toBeInstanceOf(Date); + }); + + it('should return null for null/undefined', () => { + expect(parseDate(null)).toBeNull(); + expect(parseDate(undefined)).toBeNull(); + }); + + it('should return null for invalid date', () => { + expect(parseDate('not-a-date')).toBeNull(); + expect(parseDate('')).toBeNull(); + }); +}); + +describe('parseDateString', () => { + it('should parse and return ISO string', () => { + const result = parseDateString('2024-01-15T10:30:00.000Z'); + expect(result).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should normalize date to ISO string', () => { + const result = parseDateString('2024-01-15'); + expect(result).toMatch(/^2024-01-15T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + }); + + it('should return null for null/undefined', () => { + expect(parseDateString(null)).toBeNull(); + expect(parseDateString(undefined)).toBeNull(); + }); + + it('should return null for invalid date', () => { + expect(parseDateString('invalid')).toBeNull(); + }); +}); + +describe('parse convenience object', () => { + it('should have all parsing functions', () => { + expect(parse.string).toBe(parseString); + expect(parse.optionalString).toBe(parseOptionalString); + expect(parse.stringOrNull).toBe(parseStringOrNull); + expect(parse.number).toBe(parseNumber); + expect(parse.optionalNumber).toBe(parseOptionalNumber); + expect(parse.numberOrEmpty).toBe(parseNumberOrEmpty); + expect(parse.boolean).toBe(parseBoolean); + expect(parse.optionalBoolean).toBe(parseOptionalBoolean); + expect(parse.array).toBe(parseArray); + expect(parse.filteredArray).toBe(parseFilteredArray); + expect(parse.firstArrayElement).toBe(parseFirstArrayElement); + expect(parse.nested).toBe(getNestedObject); + expect(parse.nestedValue).toBe(getNestedValue); + expect(parse.date).toBe(parseDate); + expect(parse.dateString).toBe(parseDateString); + }); + + // Real-world usage example + it('should work with real Jellyfin session parsing pattern', () => { + const rawSession = { + Id: 'session-123', + UserId: 'user-456', + UserName: 'testuser', + Client: 'Jellyfin Web', + DeviceName: 'Chrome', + NowPlayingItem: { + Id: 'item-789', + Name: 'Test Movie', + Type: 'Movie', + RunTimeTicks: 72000000000, + ProductionYear: 2024, + }, + PlayState: { + PositionTicks: 36000000000, + IsPaused: false, + }, + }; + + const parsed = { + id: parse.string(rawSession.Id), + userId: parse.string(rawSession.UserId), + userName: parse.string(rawSession.UserName), + client: parse.string(rawSession.Client), + deviceName: parse.string(rawSession.DeviceName), + nowPlayingItem: rawSession.NowPlayingItem + ? { + id: parse.string(rawSession.NowPlayingItem.Id), + name: parse.string(rawSession.NowPlayingItem.Name), + type: parse.string(rawSession.NowPlayingItem.Type), + runTimeTicks: parse.number(rawSession.NowPlayingItem.RunTimeTicks), + productionYear: parse.optionalNumber(rawSession.NowPlayingItem.ProductionYear), + } + : undefined, + playState: rawSession.PlayState + ? { + positionTicks: parse.number( + parse.nestedValue(rawSession, 'PlayState', 'PositionTicks') + ), + isPaused: parse.boolean( + parse.nestedValue(rawSession, 'PlayState', 'IsPaused') + ), + } + : undefined, + }; + + expect(parsed.id).toBe('session-123'); + expect(parsed.nowPlayingItem?.runTimeTicks).toBe(72000000000); + expect(parsed.playState?.isPaused).toBe(false); + }); + + // Real-world usage example for Plex + it('should work with real Plex session parsing pattern', () => { + const rawItem = { + sessionKey: '12345', + ratingKey: '67890', + title: 'Test Movie', + type: 'movie', + duration: 7200000, + viewOffset: 3600000, + year: 2024, + Player: { + title: 'Plex Web', + state: 'playing', + local: false, + }, + Media: [{ bitrate: 8000000 }], + }; + + const parsed = { + sessionKey: parse.string(rawItem.sessionKey), + title: parse.string(rawItem.title), + duration: parse.number(rawItem.duration), + year: parse.number(rawItem.year), + playerTitle: parse.string(parse.nestedValue(rawItem, 'Player', 'title')), + playerState: parse.string(parse.nestedValue(rawItem, 'Player', 'state')), + isLocal: parse.boolean(parse.nestedValue(rawItem, 'Player', 'local')), + bitrate: parse.number(parse.firstArrayElement(rawItem.Media, 'bitrate', 0)), + }; + + expect(parsed.sessionKey).toBe('12345'); + expect(parsed.duration).toBe(7200000); + expect(parsed.playerTitle).toBe('Plex Web'); + expect(parsed.isLocal).toBe(false); + expect(parsed.bitrate).toBe(8000000); + }); +}); diff --git a/apps/server/src/utils/__tests__/password.test.ts b/apps/server/src/utils/__tests__/password.test.ts new file mode 100644 index 0000000..8dd9594 --- /dev/null +++ b/apps/server/src/utils/__tests__/password.test.ts @@ -0,0 +1,209 @@ +/** + * Password Utility Tests + * + * Tests the ACTUAL exported functions from password.ts: + * - hashPassword: Bcrypt password hashing + * - verifyPassword: Bcrypt password verification + * + * These tests validate: + * - Hash/verify roundtrip + * - Wrong password rejection + * - Hash format validity + * - Different passwords produce different hashes + */ + +import { describe, it, expect } from 'vitest'; + +// Import ACTUAL production functions - not local duplicates +import { hashPassword, verifyPassword } from '../password.js'; + +describe('password', () => { + describe('hashPassword', () => { + it('should return a bcrypt hash string', async () => { + const hash = await hashPassword('mypassword'); + + // Bcrypt hashes start with $2b$ or $2a$ and are 60 chars + expect(hash).toMatch(/^\$2[ab]\$\d{2}\$.{53}$/); + expect(hash).toHaveLength(60); + }); + + it('should produce different hashes for same password (random salt)', async () => { + const password = 'samepassword'; + + const hash1 = await hashPassword(password); + const hash2 = await hashPassword(password); + const hash3 = await hashPassword(password); + + expect(hash1).not.toBe(hash2); + expect(hash2).not.toBe(hash3); + expect(hash1).not.toBe(hash3); + }); + + it('should handle empty string password', async () => { + const hash = await hashPassword(''); + + expect(hash).toMatch(/^\$2[ab]\$\d{2}\$.{53}$/); + + // Should still be verifiable + const isValid = await verifyPassword('', hash); + expect(isValid).toBe(true); + }); + + it('should handle unicode passwords', async () => { + const unicodePassword = '密码🔐пароль'; + const hash = await hashPassword(unicodePassword); + + expect(hash).toMatch(/^\$2[ab]\$\d{2}\$.{53}$/); + + const isValid = await verifyPassword(unicodePassword, hash); + expect(isValid).toBe(true); + }); + + it('should handle very long passwords', async () => { + // Note: bcrypt truncates at 72 bytes, but should still work + const longPassword = 'x'.repeat(100); + const hash = await hashPassword(longPassword); + + expect(hash).toMatch(/^\$2[ab]\$\d{2}\$.{53}$/); + + const isValid = await verifyPassword(longPassword, hash); + expect(isValid).toBe(true); + }); + + it('should handle special characters', async () => { + const specialPassword = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/\\`~'; + const hash = await hashPassword(specialPassword); + + const isValid = await verifyPassword(specialPassword, hash); + expect(isValid).toBe(true); + }); + }); + + describe('verifyPassword', () => { + it('should return true for correct password', async () => { + const password = 'correctpassword'; + const hash = await hashPassword(password); + + const isValid = await verifyPassword(password, hash); + + expect(isValid).toBe(true); + }); + + it('should return false for incorrect password', async () => { + const password = 'correctpassword'; + const hash = await hashPassword(password); + + const isValid = await verifyPassword('wrongpassword', hash); + + expect(isValid).toBe(false); + }); + + it('should return false for similar but different password', async () => { + const password = 'MyPassword123'; + const hash = await hashPassword(password); + + // Case difference + expect(await verifyPassword('mypassword123', hash)).toBe(false); + // Extra character + expect(await verifyPassword('MyPassword1234', hash)).toBe(false); + // Missing character + expect(await verifyPassword('MyPassword12', hash)).toBe(false); + // Whitespace + expect(await verifyPassword(' MyPassword123', hash)).toBe(false); + expect(await verifyPassword('MyPassword123 ', hash)).toBe(false); + }); + + it('should reject empty password against non-empty hash', async () => { + const hash = await hashPassword('realpassword'); + + const isValid = await verifyPassword('', hash); + + expect(isValid).toBe(false); + }); + + it('should handle hash from different password correctly', async () => { + const hash1 = await hashPassword('password1'); + const hash2 = await hashPassword('password2'); + + // Password 1 should not verify against hash 2 + expect(await verifyPassword('password1', hash2)).toBe(false); + // Password 2 should not verify against hash 1 + expect(await verifyPassword('password2', hash1)).toBe(false); + }); + }); + + describe('roundtrip scenarios', () => { + it('should work for typical user registration/login flow', async () => { + // User registers with password + const userPassword = 'SecureP@ssw0rd!'; + const storedHash = await hashPassword(userPassword); + + // Store hash in database (just verify format) + expect(storedHash).not.toBe(userPassword); + expect(storedHash).not.toContain(userPassword); + + // User logs in with correct password + const loginAttempt1 = await verifyPassword(userPassword, storedHash); + expect(loginAttempt1).toBe(true); + + // Attacker tries wrong password + const loginAttempt2 = await verifyPassword('wrongpassword', storedHash); + expect(loginAttempt2).toBe(false); + }); + + it('should work for password change flow', async () => { + const oldPassword = 'OldPassword123'; + const newPassword = 'NewPassword456'; + + // Original hash + const oldHash = await hashPassword(oldPassword); + + // User changes password + const newHash = await hashPassword(newPassword); + + // Old password should not work with new hash + expect(await verifyPassword(oldPassword, newHash)).toBe(false); + + // New password should work with new hash + expect(await verifyPassword(newPassword, newHash)).toBe(true); + + // New password should not work with old hash + expect(await verifyPassword(newPassword, oldHash)).toBe(false); + }); + + it('should handle multiple users with same password', async () => { + const commonPassword = 'password123'; + + const user1Hash = await hashPassword(commonPassword); + const user2Hash = await hashPassword(commonPassword); + + // Hashes should be different (different salts) + expect(user1Hash).not.toBe(user2Hash); + + // But both should verify with the same password + expect(await verifyPassword(commonPassword, user1Hash)).toBe(true); + expect(await verifyPassword(commonPassword, user2Hash)).toBe(true); + }); + }); + + describe('security properties', () => { + it('should use cost factor of at least 10', async () => { + const hash = await hashPassword('test'); + + // Extract cost factor from hash: $2b$XX$... + const costMatch = hash.match(/^\$2[ab]\$(\d{2})\$/); + expect(costMatch).not.toBeNull(); + + const costFactor = parseInt(costMatch![1]!, 10); + expect(costFactor).toBeGreaterThanOrEqual(10); + }); + + it('should not expose password in hash', async () => { + const password = 'secretpassword'; + const hash = await hashPassword(password); + + // Hash should not contain the password + expect(hash.toLowerCase()).not.toContain(password.toLowerCase()); + }); + }); +}); diff --git a/apps/server/src/utils/__tests__/serverFiltering.test.ts b/apps/server/src/utils/__tests__/serverFiltering.test.ts new file mode 100644 index 0000000..ac1894f --- /dev/null +++ b/apps/server/src/utils/__tests__/serverFiltering.test.ts @@ -0,0 +1,215 @@ +/** + * Server Filtering Utility Tests + * + * Tests the server access control functions: + * - buildServerAccessCondition: Build SQL conditions for server access + * - buildServerFilterCondition: Build conditions with explicit serverId validation + * - filterByServerAccess: Filter arrays by server access + * - hasServerAccess: Check if user has server access + * - validateServerAccess: Validate and return error message + */ + +import { describe, it, expect } from 'vitest'; +import type { AuthUser } from '@tracearr/shared'; +import { + buildServerAccessCondition, + buildServerFilterCondition, + filterByServerAccess, + hasServerAccess, + validateServerAccess, +} from '../serverFiltering.js'; +import type { Column } from 'drizzle-orm'; + +// Mock column for testing SQL condition builders +const mockServerIdColumn = { + name: 'serverId', +} as unknown as Column; + +// Test fixtures +const ownerUser: AuthUser = { + userId: 'owner-1', + username: 'owner', + role: 'owner', + serverIds: [], +}; + +const adminUserSingleServer: AuthUser = { + userId: 'admin-1', + username: 'admin', + role: 'admin', + serverIds: ['server-1'], +}; + +const adminUserMultiServer: AuthUser = { + userId: 'admin-2', + username: 'admin2', + role: 'admin', + serverIds: ['server-1', 'server-2'], +}; + +const adminUserNoServers: AuthUser = { + userId: 'admin-3', + username: 'admin3', + role: 'admin', + serverIds: [], +}; + +describe('filterByServerAccess', () => { + const items = [ + { id: '1', serverId: 'server-1', name: 'Item 1' }, + { id: '2', serverId: 'server-2', name: 'Item 2' }, + { id: '3', serverId: 'server-3', name: 'Item 3' }, + ]; + + it('should return all items for owner', () => { + const result = filterByServerAccess(items, ownerUser); + expect(result).toHaveLength(3); + expect(result).toEqual(items); + }); + + it('should filter to accessible servers for admin', () => { + const result = filterByServerAccess(items, adminUserSingleServer); + expect(result).toHaveLength(1); + expect(result[0]?.serverId).toBe('server-1'); + }); + + it('should filter to multiple servers for admin with multi-server access', () => { + const result = filterByServerAccess(items, adminUserMultiServer); + expect(result).toHaveLength(2); + expect(result.map((i) => i.serverId)).toEqual(['server-1', 'server-2']); + }); + + it('should return empty array for admin with no server access', () => { + const result = filterByServerAccess(items, adminUserNoServers); + expect(result).toHaveLength(0); + }); + + it('should handle empty items array', () => { + const result = filterByServerAccess([], adminUserSingleServer); + expect(result).toHaveLength(0); + }); +}); + +describe('hasServerAccess', () => { + it('should return true for owner regardless of serverId', () => { + expect(hasServerAccess(ownerUser, 'any-server')).toBe(true); + expect(hasServerAccess(ownerUser, 'server-1')).toBe(true); + expect(hasServerAccess(ownerUser, '')).toBe(true); + }); + + it('should return true when user has access to specific server', () => { + expect(hasServerAccess(adminUserSingleServer, 'server-1')).toBe(true); + }); + + it('should return false when user does not have access', () => { + expect(hasServerAccess(adminUserSingleServer, 'server-2')).toBe(false); + expect(hasServerAccess(adminUserSingleServer, 'unknown')).toBe(false); + }); + + it('should return false for user with no server access', () => { + expect(hasServerAccess(adminUserNoServers, 'server-1')).toBe(false); + }); + + it('should check multiple servers correctly', () => { + expect(hasServerAccess(adminUserMultiServer, 'server-1')).toBe(true); + expect(hasServerAccess(adminUserMultiServer, 'server-2')).toBe(true); + expect(hasServerAccess(adminUserMultiServer, 'server-3')).toBe(false); + }); +}); + +describe('validateServerAccess', () => { + it('should return null for owner (access granted)', () => { + expect(validateServerAccess(ownerUser, 'any-server')).toBeNull(); + }); + + it('should return null when user has access', () => { + expect(validateServerAccess(adminUserSingleServer, 'server-1')).toBeNull(); + }); + + it('should return error message when access denied', () => { + const error = validateServerAccess(adminUserSingleServer, 'server-2'); + expect(error).toBe('You do not have access to this server'); + }); + + it('should return error message for user with no servers', () => { + const error = validateServerAccess(adminUserNoServers, 'server-1'); + expect(error).toBe('You do not have access to this server'); + }); +}); + +describe('buildServerAccessCondition', () => { + it('should return undefined for owner (no filtering)', () => { + const result = buildServerAccessCondition(ownerUser, mockServerIdColumn); + expect(result).toBeUndefined(); + }); + + it('should return sql`false` for user with no server access', () => { + const result = buildServerAccessCondition(adminUserNoServers, mockServerIdColumn); + expect(result).toBeDefined(); + // The result should be a SQL object (we just verify it's defined) + }); + + it('should return equality condition for single server', () => { + const result = buildServerAccessCondition(adminUserSingleServer, mockServerIdColumn); + expect(result).toBeDefined(); + // Single server should use eq() which is more efficient + }); + + it('should return IN clause for multiple servers', () => { + const result = buildServerAccessCondition(adminUserMultiServer, mockServerIdColumn); + expect(result).toBeDefined(); + // Multiple servers should use inArray() + }); +}); + +describe('buildServerFilterCondition', () => { + it('should return error when user lacks access to requested server', () => { + const result = buildServerFilterCondition( + adminUserSingleServer, + 'server-2', + mockServerIdColumn + ); + expect(result.error).toBe('You do not have access to this server'); + expect(result.condition).toBeUndefined(); + }); + + it('should return condition when user has access to requested server', () => { + const result = buildServerFilterCondition( + adminUserSingleServer, + 'server-1', + mockServerIdColumn + ); + expect(result.error).toBeNull(); + expect(result.condition).toBeDefined(); + }); + + it('should allow owner to access any server', () => { + const result = buildServerFilterCondition( + ownerUser, + 'any-server', + mockServerIdColumn + ); + expect(result.error).toBeNull(); + expect(result.condition).toBeDefined(); + }); + + it('should fall back to server access condition when no explicit serverId', () => { + const result = buildServerFilterCondition( + adminUserSingleServer, + undefined, + mockServerIdColumn + ); + expect(result.error).toBeNull(); + // Should return the buildServerAccessCondition result + }); + + it('should return undefined condition for owner with no explicit serverId', () => { + const result = buildServerFilterCondition( + ownerUser, + undefined, + mockServerIdColumn + ); + expect(result.error).toBeNull(); + expect(result.condition).toBeUndefined(); // Owners see all + }); +}); diff --git a/apps/server/src/utils/__tests__/unitConversion.test.ts b/apps/server/src/utils/__tests__/unitConversion.test.ts new file mode 100644 index 0000000..8edb66a --- /dev/null +++ b/apps/server/src/utils/__tests__/unitConversion.test.ts @@ -0,0 +1,317 @@ +/** + * Unit Conversion Utility Tests + * + * Tests the unit conversion functions from @tracearr/shared: + * - kmToMiles: Convert kilometers to miles + * - milesToKm: Convert miles to kilometers + * - formatDistance: Format distance with unit label + * - formatSpeed: Format speed with unit label + * - getDistanceUnit: Get distance unit string + * - getSpeedUnit: Get speed unit string + * - toMetricDistance: Convert display value to internal metric + * - fromMetricDistance: Convert internal metric to display value + */ + +import { describe, it, expect } from 'vitest'; +import { + UNIT_CONVERSION, + kmToMiles, + milesToKm, + formatDistance, + formatSpeed, + getDistanceUnit, + getSpeedUnit, + toMetricDistance, + fromMetricDistance, +} from '@tracearr/shared'; + +describe('Unit Conversion Constants', () => { + it('should have correct conversion factors', () => { + expect(UNIT_CONVERSION.KM_TO_MILES).toBe(0.621371); + expect(UNIT_CONVERSION.MILES_TO_KM).toBe(1.60934); + }); + + it('conversion factors should be approximate inverses', () => { + // Converting 1 km to miles and back should give ~1 km + const roundTrip = kmToMiles(1) * UNIT_CONVERSION.MILES_TO_KM; + expect(roundTrip).toBeCloseTo(1, 4); + }); +}); + +describe('kmToMiles', () => { + it('should convert 0 km to 0 miles', () => { + expect(kmToMiles(0)).toBe(0); + }); + + it('should convert 1 km to approximately 0.621 miles', () => { + expect(kmToMiles(1)).toBeCloseTo(0.621371, 5); + }); + + it('should convert 100 km to approximately 62.14 miles', () => { + expect(kmToMiles(100)).toBeCloseTo(62.1371, 3); + }); + + it('should convert 1000 km to approximately 621.37 miles', () => { + expect(kmToMiles(1000)).toBeCloseTo(621.371, 2); + }); + + it('should handle decimal values', () => { + expect(kmToMiles(1.5)).toBeCloseTo(0.9320565, 5); + }); + + it('should handle negative values (edge case)', () => { + expect(kmToMiles(-10)).toBeCloseTo(-6.21371, 4); + }); +}); + +describe('milesToKm', () => { + it('should convert 0 miles to 0 km', () => { + expect(milesToKm(0)).toBe(0); + }); + + it('should convert 1 mile to approximately 1.609 km', () => { + expect(milesToKm(1)).toBeCloseTo(1.60934, 4); + }); + + it('should convert 62.14 miles to approximately 100 km', () => { + expect(milesToKm(62.14)).toBeCloseTo(100, 0); + }); + + it('should handle decimal values', () => { + expect(milesToKm(0.5)).toBeCloseTo(0.80467, 4); + }); +}); + +describe('formatDistance', () => { + describe('metric system', () => { + it('should format 0 km correctly', () => { + expect(formatDistance(0, 'metric')).toBe('0 km'); + }); + + it('should format integer km correctly', () => { + expect(formatDistance(100, 'metric')).toBe('100 km'); + }); + + it('should format km with decimals when specified', () => { + expect(formatDistance(100.567, 'metric', 2)).toBe('100.57 km'); + }); + + it('should round to 0 decimals by default', () => { + expect(formatDistance(100.9, 'metric')).toBe('101 km'); + }); + + it('should handle large distances', () => { + expect(formatDistance(12500, 'metric')).toBe('12500 km'); + }); + }); + + describe('imperial system', () => { + it('should format 0 km as 0 mi', () => { + expect(formatDistance(0, 'imperial')).toBe('0 mi'); + }); + + it('should convert 100 km to approximately 62 mi', () => { + expect(formatDistance(100, 'imperial')).toBe('62 mi'); + }); + + it('should format with decimals when specified', () => { + expect(formatDistance(100, 'imperial', 2)).toBe('62.14 mi'); + }); + + it('should handle 1 km conversion', () => { + expect(formatDistance(1, 'imperial', 2)).toBe('0.62 mi'); + }); + + it('should handle large distances', () => { + expect(formatDistance(1000, 'imperial')).toBe('621 mi'); + }); + }); +}); + +describe('formatSpeed', () => { + describe('metric system', () => { + it('should format 0 km/h correctly', () => { + expect(formatSpeed(0, 'metric')).toBe('0 km/h'); + }); + + it('should format integer speeds correctly', () => { + expect(formatSpeed(100, 'metric')).toBe('100 km/h'); + }); + + it('should format speeds with decimals when specified', () => { + expect(formatSpeed(100.567, 'metric', 1)).toBe('100.6 km/h'); + }); + + it('should round to 0 decimals by default', () => { + expect(formatSpeed(65.8, 'metric')).toBe('66 km/h'); + }); + + it('should handle typical impossible travel speeds', () => { + expect(formatSpeed(1500, 'metric')).toBe('1500 km/h'); + }); + }); + + describe('imperial system', () => { + it('should format 0 km/h as 0 mph', () => { + expect(formatSpeed(0, 'imperial')).toBe('0 mph'); + }); + + it('should convert 100 km/h to approximately 62 mph', () => { + expect(formatSpeed(100, 'imperial')).toBe('62 mph'); + }); + + it('should format with decimals when specified', () => { + expect(formatSpeed(100, 'imperial', 1)).toBe('62.1 mph'); + }); + + it('should handle 60 km/h (common speed limit)', () => { + expect(formatSpeed(60, 'imperial')).toBe('37 mph'); + }); + + it('should handle impossible travel speeds', () => { + // 1500 km/h = ~932 mph (supersonic) + expect(formatSpeed(1500, 'imperial')).toBe('932 mph'); + }); + }); +}); + +describe('getDistanceUnit', () => { + it('should return "km" for metric', () => { + expect(getDistanceUnit('metric')).toBe('km'); + }); + + it('should return "mi" for imperial', () => { + expect(getDistanceUnit('imperial')).toBe('mi'); + }); +}); + +describe('getSpeedUnit', () => { + it('should return "km/h" for metric', () => { + expect(getSpeedUnit('metric')).toBe('km/h'); + }); + + it('should return "mph" for imperial', () => { + expect(getSpeedUnit('imperial')).toBe('mph'); + }); +}); + +describe('toMetricDistance', () => { + describe('metric system', () => { + it('should return the same value for metric', () => { + expect(toMetricDistance(100, 'metric')).toBe(100); + }); + + it('should not convert 0', () => { + expect(toMetricDistance(0, 'metric')).toBe(0); + }); + + it('should preserve decimal values', () => { + expect(toMetricDistance(100.567, 'metric')).toBe(100.567); + }); + }); + + describe('imperial system', () => { + it('should convert miles to km', () => { + expect(toMetricDistance(62.14, 'imperial')).toBeCloseTo(100, 0); + }); + + it('should convert 0 miles to 0 km', () => { + expect(toMetricDistance(0, 'imperial')).toBe(0); + }); + + it('should convert 1 mile to approximately 1.609 km', () => { + expect(toMetricDistance(1, 'imperial')).toBeCloseTo(1.60934, 4); + }); + }); +}); + +describe('fromMetricDistance', () => { + describe('metric system', () => { + it('should return the same value for metric', () => { + expect(fromMetricDistance(100, 'metric')).toBe(100); + }); + + it('should not convert 0', () => { + expect(fromMetricDistance(0, 'metric')).toBe(0); + }); + + it('should preserve decimal values', () => { + expect(fromMetricDistance(100.567, 'metric')).toBe(100.567); + }); + }); + + describe('imperial system', () => { + it('should convert km to miles', () => { + expect(fromMetricDistance(100, 'imperial')).toBeCloseTo(62.1371, 3); + }); + + it('should convert 0 km to 0 miles', () => { + expect(fromMetricDistance(0, 'imperial')).toBe(0); + }); + + it('should convert 1.609 km to approximately 1 mile', () => { + expect(fromMetricDistance(1.60934, 'imperial')).toBeCloseTo(1, 3); + }); + }); +}); + +describe('Round-trip conversions', () => { + it('should preserve value through metric -> display -> metric for metric system', () => { + const original = 150; + const display = fromMetricDistance(original, 'metric'); + const restored = toMetricDistance(display, 'metric'); + expect(restored).toBe(original); + }); + + it('should approximately preserve value through metric -> display -> metric for imperial', () => { + const original = 150; + const display = fromMetricDistance(original, 'imperial'); + const restored = toMetricDistance(display, 'imperial'); + // Conversion factors are not exact inverses, so allow ~0.001% error + expect(restored).toBeCloseTo(original, 2); + }); + + it('should handle typical rule threshold values', () => { + // 100 km threshold -> display in miles -> back to km + const thresholdKm = 100; + const displayMiles = fromMetricDistance(thresholdKm, 'imperial'); + expect(displayMiles).toBeCloseTo(62.1371, 3); + + const backToKm = toMetricDistance(displayMiles, 'imperial'); + // Conversion factors are not exact inverses, so allow ~0.001% error + expect(backToKm).toBeCloseTo(100, 2); + }); +}); + +describe('Real-world scenarios', () => { + describe('impossible travel rule', () => { + it('should format typical violation speed (1000 km/h)', () => { + expect(formatSpeed(1000, 'metric')).toBe('1000 km/h'); + expect(formatSpeed(1000, 'imperial')).toBe('621 mph'); + }); + + it('should format airplane-level speeds (900 km/h)', () => { + expect(formatSpeed(900, 'metric')).toBe('900 km/h'); + expect(formatSpeed(900, 'imperial')).toBe('559 mph'); + }); + + it('should format distance between cities (500 km)', () => { + expect(formatDistance(500, 'metric')).toBe('500 km'); + expect(formatDistance(500, 'imperial')).toBe('311 mi'); + }); + }); + + describe('distance threshold display', () => { + it('should convert 50 km threshold for imperial users', () => { + const thresholdKm = 50; + const displayValue = fromMetricDistance(thresholdKm, 'imperial'); + expect(Math.round(displayValue)).toBe(31); // ~31 miles + }); + + it('should convert user input of 30 miles back to km', () => { + const userInput = 30; // miles + const storedValue = toMetricDistance(userInput, 'imperial'); + expect(storedValue).toBeCloseTo(48.28, 1); // ~48 km + }); + }); +}); diff --git a/apps/server/src/utils/crypto.ts b/apps/server/src/utils/crypto.ts new file mode 100644 index 0000000..b572cdc --- /dev/null +++ b/apps/server/src/utils/crypto.ts @@ -0,0 +1,163 @@ +/** + * Encryption utilities for sensitive data (server tokens, API keys) + * Uses AES-256-GCM for authenticated encryption + * + * NOTE: Token encryption is being phased out. This module now primarily + * supports migrating existing encrypted tokens to plain text storage. + * New tokens are stored in plain text (the DB is localhost-only in supervised mode). + */ + +import { createDecipheriv } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; // 128 bits +const AUTH_TAG_LENGTH = 16; // 128 bits +const KEY_LENGTH = 32; // 256 bits + +// Minimum length for an encrypted token: IV (16) + AuthTag (16) + at least 1 byte ciphertext, base64 encoded +const MIN_ENCRYPTED_LENGTH = Math.ceil((IV_LENGTH + AUTH_TAG_LENGTH + 1) * 4 / 3); + +let encryptionKey: Buffer | null = null; + +/** + * Initialize the encryption module with the key from environment. + * This is now optional - only needed for migrating existing encrypted tokens. + * Returns true if initialized, false if no key available. + */ +export function initializeEncryption(): boolean { + const keyHex = process.env.ENCRYPTION_KEY; + + if (!keyHex) { + // No encryption key - that's fine, tokens will be stored in plain text + return false; + } + + // Key should be 32 bytes (64 hex chars) for AES-256 + if (keyHex.length !== KEY_LENGTH * 2) { + // Invalid key format - log warning but don't fail + console.warn( + `ENCRYPTION_KEY has invalid length (${keyHex.length} chars, expected ${KEY_LENGTH * 2}). ` + + 'Encrypted token migration will be skipped.' + ); + return false; + } + + encryptionKey = Buffer.from(keyHex, 'hex'); + return true; +} + +/** + * Check if encryption is initialized (key is available for decryption) + */ +export function isEncryptionInitialized(): boolean { + return encryptionKey !== null; +} + +/** + * Get the encryption key, returning null if not initialized + */ +function getKey(): Buffer | null { + return encryptionKey; +} + +/** + * Check if a token looks like it might be encrypted. + * Encrypted tokens are base64-encoded and have a minimum length. + * Plain text tokens (Plex/Jellyfin API keys) are typically shorter alphanumeric strings. + */ +export function looksEncrypted(token: string): boolean { + // Too short to be encrypted + if (token.length < MIN_ENCRYPTED_LENGTH) { + return false; + } + + // Check if it's valid base64 (encrypted tokens are base64) + try { + const decoded = Buffer.from(token, 'base64'); + // Re-encode and compare - if it's valid base64, it should round-trip + const reencoded = decoded.toString('base64'); + // Allow for padding differences + if (reencoded.replace(/=+$/, '') !== token.replace(/=+$/, '')) { + return false; + } + // Must be at least IV + AuthTag + 1 byte + return decoded.length >= IV_LENGTH + AUTH_TAG_LENGTH + 1; + } catch { + return false; + } +} + +/** + * Try to decrypt a value. Returns the decrypted string if successful, + * or null if decryption fails (wrong key, not encrypted, etc.) + */ +export function tryDecrypt(encryptedValue: string): string | null { + const key = getKey(); + if (!key) { + return null; + } + + try { + // Decode the combined value + const combined = Buffer.from(encryptedValue, 'base64'); + + // Must have at least IV + AuthTag + 1 byte of ciphertext + if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) { + return null; + } + + // Extract iv, authTag, and ciphertext + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString('utf8'); + } catch { + // Decryption failed - wrong key, corrupted data, or not encrypted + return null; + } +} + +/** + * Decrypt a value encrypted with encrypt() + * Returns the original plaintext string + * @throws Error if decryption fails + * @deprecated Use tryDecrypt() for migration, store tokens in plain text going forward + */ +export function decrypt(encryptedValue: string): string { + const result = tryDecrypt(encryptedValue); + if (result === null) { + throw new Error('Decryption failed - encryption key may have changed or data is corrupted'); + } + return result; +} + +/** + * Migrate a token from encrypted to plain text format. + * Returns the plain text token, whether it was encrypted or not. + * + * @param token - The token (may be encrypted or plain text) + * @returns Object with plainText token and whether migration occurred + */ +export function migrateToken(token: string): { plainText: string; wasEncrypted: boolean } { + // If it doesn't look encrypted, assume it's already plain text + if (!looksEncrypted(token)) { + return { plainText: token, wasEncrypted: false }; + } + + // Try to decrypt it + const decrypted = tryDecrypt(token); + if (decrypted !== null) { + return { plainText: decrypted, wasEncrypted: true }; + } + + // Looks encrypted but couldn't decrypt - might be plain text that happens to look like base64, + // or encrypted with a different key. Return as-is and let it fail at runtime if it's wrong. + return { plainText: token, wasEncrypted: false }; +} diff --git a/apps/server/src/utils/errors.ts b/apps/server/src/utils/errors.ts new file mode 100644 index 0000000..d9b06e6 --- /dev/null +++ b/apps/server/src/utils/errors.ts @@ -0,0 +1,283 @@ +/** + * Standardized error classes for Tracearr API + * All errors return consistent ApiError format from @tracearr/shared + */ + +import type { FastifyInstance, FastifyError } from 'fastify'; +import type { ZodError } from 'zod'; +import type { ApiError } from '@tracearr/shared'; + +// Error codes for client identification +export const ErrorCodes = { + // Authentication (1xxx) + UNAUTHORIZED: 'AUTH_001', + INVALID_TOKEN: 'AUTH_002', + TOKEN_EXPIRED: 'AUTH_003', + INSUFFICIENT_PERMISSIONS: 'AUTH_004', + + // Validation (2xxx) + VALIDATION_ERROR: 'VAL_001', + INVALID_INPUT: 'VAL_002', + MISSING_FIELD: 'VAL_003', + + // Resource (3xxx) + NOT_FOUND: 'RES_001', + ALREADY_EXISTS: 'RES_002', + CONFLICT: 'RES_003', + + // Server (4xxx) + INTERNAL_ERROR: 'SRV_001', + SERVICE_UNAVAILABLE: 'SRV_002', + DATABASE_ERROR: 'SRV_003', + REDIS_ERROR: 'SRV_004', + + // Rate limiting (5xxx) + RATE_LIMITED: 'RATE_001', + + // External services (6xxx) + PLEX_ERROR: 'EXT_001', + JELLYFIN_ERROR: 'EXT_002', + GEOIP_ERROR: 'EXT_003', + EMBY_ERROR: 'EXT_004', +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** + * Base application error class + */ +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: ErrorCode; + public readonly isOperational: boolean; + public readonly details?: Record; + + constructor( + message: string, + statusCode: number, + code: ErrorCode, + details?: Record + ) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + this.details = details; + + // Maintains proper stack trace for where error was thrown + Error.captureStackTrace(this, this.constructor); + Object.setPrototypeOf(this, AppError.prototype); + } + + toJSON(): ApiError & { code: ErrorCode; details?: Record } { + return { + statusCode: this.statusCode, + error: this.name, + message: this.message, + code: this.code, + ...(this.details && { details: this.details }), + }; + } +} + +/** + * Validation error - 400 Bad Request + */ +export class ValidationError extends AppError { + public readonly fields?: Array<{ field: string; message: string }>; + + constructor( + message: string, + fields?: Array<{ field: string; message: string }> + ) { + super(message, 400, ErrorCodes.VALIDATION_ERROR, fields ? { fields } : undefined); + this.name = 'ValidationError'; + this.fields = fields; + Object.setPrototypeOf(this, ValidationError.prototype); + } + + static fromZodError(error: ZodError): ValidationError { + const fields = error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); + return new ValidationError('Validation failed', fields); + } +} + +/** + * Authentication error - 401 Unauthorized + */ +export class AuthenticationError extends AppError { + constructor(message = 'Authentication required') { + super(message, 401, ErrorCodes.UNAUTHORIZED); + this.name = 'AuthenticationError'; + Object.setPrototypeOf(this, AuthenticationError.prototype); + } +} + +/** + * Authorization error - 403 Forbidden + */ +export class ForbiddenError extends AppError { + constructor(message = 'Access denied') { + super(message, 403, ErrorCodes.INSUFFICIENT_PERMISSIONS); + this.name = 'ForbiddenError'; + Object.setPrototypeOf(this, ForbiddenError.prototype); + } +} + +/** + * Not found error - 404 Not Found + */ +export class NotFoundError extends AppError { + constructor(resource = 'Resource', id?: string) { + const message = id ? `${resource} with ID '${id}' not found` : `${resource} not found`; + super(message, 404, ErrorCodes.NOT_FOUND); + this.name = 'NotFoundError'; + Object.setPrototypeOf(this, NotFoundError.prototype); + } +} + +/** + * Conflict error - 409 Conflict + */ +export class ConflictError extends AppError { + constructor(message: string) { + super(message, 409, ErrorCodes.CONFLICT); + this.name = 'ConflictError'; + Object.setPrototypeOf(this, ConflictError.prototype); + } +} + +/** + * Rate limit error - 429 Too Many Requests + */ +export class RateLimitError extends AppError { + public readonly retryAfter?: number; + + constructor(message = 'Too many requests', retryAfter?: number) { + super(message, 429, ErrorCodes.RATE_LIMITED, retryAfter ? { retryAfter } : undefined); + this.name = 'RateLimitError'; + this.retryAfter = retryAfter; + Object.setPrototypeOf(this, RateLimitError.prototype); + } +} + +/** + * Internal server error - 500 Internal Server Error + */ +export class InternalError extends AppError { + constructor(message = 'An unexpected error occurred') { + super(message, 500, ErrorCodes.INTERNAL_ERROR); + this.name = 'InternalError'; + // Not operational - these are bugs that need investigation + (this as { isOperational: boolean }).isOperational = false; + Object.setPrototypeOf(this, InternalError.prototype); + } +} + +/** + * Database error - 500 Internal Server Error + */ +export class DatabaseError extends AppError { + constructor(message = 'Database operation failed') { + super(message, 500, ErrorCodes.DATABASE_ERROR); + this.name = 'DatabaseError'; + Object.setPrototypeOf(this, DatabaseError.prototype); + } +} + +/** + * Service unavailable - 503 Service Unavailable + */ +export class ServiceUnavailableError extends AppError { + constructor(service: string) { + super(`${service} is currently unavailable`, 503, ErrorCodes.SERVICE_UNAVAILABLE); + this.name = 'ServiceUnavailableError'; + Object.setPrototypeOf(this, ServiceUnavailableError.prototype); + } +} + +/** + * External service error (Plex, Jellyfin, Emby, etc.) + */ +export class ExternalServiceError extends AppError { + constructor(service: 'plex' | 'jellyfin' | 'emby' | 'geoip', message: string) { + const codeMap: Record = { + plex: ErrorCodes.PLEX_ERROR, + jellyfin: ErrorCodes.JELLYFIN_ERROR, + emby: ErrorCodes.EMBY_ERROR, + geoip: ErrorCodes.GEOIP_ERROR, + }; + const code = codeMap[service]; + super(`${service.charAt(0).toUpperCase() + service.slice(1)} error: ${message}`, 502, code); + this.name = 'ExternalServiceError'; + Object.setPrototypeOf(this, ExternalServiceError.prototype); + } +} + +/** + * Register global error handler for Fastify + */ +export function registerErrorHandler(app: FastifyInstance): void { + app.setErrorHandler((error: FastifyError | AppError | Error, request, reply) => { + // Log the error + request.log.error( + { + err: error, + requestId: request.id, + url: request.url, + method: request.method, + }, + 'Request error' + ); + + // Handle our custom AppError + if (error instanceof AppError) { + return reply.status(error.statusCode).send(error.toJSON()); + } + + // Handle Fastify validation errors + if ('validation' in error && error.validation) { + const validationError = new ValidationError( + 'Validation failed', + error.validation.map((v) => ({ + field: v.instancePath || 'unknown', + message: v.message ?? 'Invalid value', + })) + ); + return reply.status(400).send(validationError.toJSON()); + } + + // Handle Fastify sensible errors (unauthorized, forbidden, etc.) + if ('statusCode' in error && typeof error.statusCode === 'number') { + const response: ApiError = { + statusCode: error.statusCode, + error: error.name || 'Error', + message: error.message, + }; + return reply.status(error.statusCode).send(response); + } + + // Handle unknown errors + const isProduction = process.env.NODE_ENV === 'production'; + const response: ApiError = { + statusCode: 500, + error: 'InternalServerError', + message: isProduction ? 'An unexpected error occurred' : error.message, + }; + + return reply.status(500).send(response); + }); + + // Handle 404 Not Found + app.setNotFoundHandler((request, reply) => { + const response: ApiError = { + statusCode: 404, + error: 'NotFound', + message: `Route ${request.method} ${request.url} not found`, + }; + return reply.status(404).send(response); + }); +} diff --git a/apps/server/src/utils/http.ts b/apps/server/src/utils/http.ts new file mode 100644 index 0000000..6fb7f4c --- /dev/null +++ b/apps/server/src/utils/http.ts @@ -0,0 +1,289 @@ +/** + * HTTP Client Utilities + * + * Provides a consistent interface for making HTTP requests with: + * - Automatic error handling and typed errors + * - Service-specific error codes integration + * - Support for JSON, text, and raw response handling + * - Request timeout support + */ + +import { ExternalServiceError } from './errors.js'; + +/** + * HTTP client error with service context + */ +export class HttpClientError extends Error { + public readonly statusCode: number; + public readonly statusText: string; + public readonly service: string; + public readonly url: string; + public readonly responseBody?: string; + + constructor(options: { + service: string; + statusCode: number; + statusText: string; + url: string; + message?: string; + responseBody?: string; + }) { + const message = + options.message || + `${options.service} request failed: ${options.statusCode} ${options.statusText}`; + super(message); + this.name = 'HttpClientError'; + this.service = options.service; + this.statusCode = options.statusCode; + this.statusText = options.statusText; + this.url = options.url; + this.responseBody = options.responseBody; + + Error.captureStackTrace(this, this.constructor); + Object.setPrototypeOf(this, HttpClientError.prototype); + } + + /** + * Convert to ExternalServiceError for known services + */ + toExternalServiceError(): ExternalServiceError | this { + if (this.service === 'plex' || this.service === 'jellyfin' || this.service === 'emby' || this.service === 'geoip') { + return new ExternalServiceError(this.service, this.message); + } + return this; + } +} + +/** + * Options for HTTP requests + */ +export interface HttpRequestOptions extends Omit { + /** Service name for error messages */ + service?: string; + /** Request timeout in milliseconds */ + timeout?: number; + /** Whether to include response body in errors */ + includeBodyInError?: boolean; +} + +/** + * Check if response is OK, throw HttpClientError if not + */ +async function assertResponseOk( + response: Response, + url: string, + options: HttpRequestOptions +): Promise { + if (response.ok) return; + + let responseBody: string | undefined; + if (options.includeBodyInError) { + try { + responseBody = await response.text(); + } catch { + // Ignore - body might already be consumed or unavailable + } + } + + throw new HttpClientError({ + service: options.service || 'API', + statusCode: response.status, + statusText: response.statusText, + url, + responseBody, + }); +} + +/** + * Create an AbortSignal with timeout + */ +function createTimeoutSignal(timeoutMs: number): AbortSignal { + return AbortSignal.timeout(timeoutMs); +} + +/** + * Fetch JSON data from a URL + * + * @example + * const data = await fetchJson('https://api.example.com/user', { + * service: 'example', + * headers: { 'Authorization': 'Bearer token' } + * }); + */ +export async function fetchJson( + url: string, + options: HttpRequestOptions = {} +): Promise { + const { timeout, ...fetchOptions } = options; + + const response = await fetch(url, { + ...fetchOptions, + signal: timeout ? createTimeoutSignal(timeout) : undefined, + }); + + await assertResponseOk(response, url, options); + + return response.json() as Promise; +} + +/** + * Fetch text content from a URL + * + * @example + * const xml = await fetchText('https://api.example.com/data.xml', { + * service: 'example' + * }); + */ +export async function fetchText( + url: string, + options: HttpRequestOptions = {} +): Promise { + const { timeout, ...fetchOptions } = options; + + const response = await fetch(url, { + ...fetchOptions, + signal: timeout ? createTimeoutSignal(timeout) : undefined, + }); + + await assertResponseOk(response, url, options); + + return response.text(); +} + +/** + * Fetch raw Response object (for streaming, binary data, etc.) + * Still validates response.ok + * + * @example + * const response = await fetchRaw('https://api.example.com/image.png', { + * service: 'example' + * }); + * const buffer = await response.arrayBuffer(); + */ +export async function fetchRaw( + url: string, + options: HttpRequestOptions = {} +): Promise { + const { timeout, ...fetchOptions } = options; + + const response = await fetch(url, { + ...fetchOptions, + signal: timeout ? createTimeoutSignal(timeout) : undefined, + }); + + await assertResponseOk(response, url, options); + + return response; +} + +/** + * Fetch with full response info (status, headers, body) + * Does NOT throw on non-2xx responses + * + * @example + * const { ok, status, data } = await fetchWithStatus('https://api.example.com/user'); + * if (!ok) { + * console.log('Request failed with status', status); + * } + */ +export async function fetchWithStatus( + url: string, + options: HttpRequestOptions = {} +): Promise<{ + ok: boolean; + status: number; + statusText: string; + headers: Headers; + data: T | null; +}> { + const { timeout, ...fetchOptions } = options; + + const response = await fetch(url, { + ...fetchOptions, + signal: timeout ? createTimeoutSignal(timeout) : undefined, + }); + + let data: T | null = null; + if (response.ok) { + try { + data = (await response.json()) as T; + } catch { + // Response might not be JSON + } + } + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: response.headers, + data, + }; +} + +/** + * Helper to create common headers for JSON APIs + */ +export function jsonHeaders(token?: string): Record { + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; +} + +/** + * Helper to create Plex-specific headers + */ +export function plexHeaders(token?: string): Record { + const headers: Record = { + 'Accept': 'application/json', + 'X-Plex-Client-Identifier': 'tracearr', + 'X-Plex-Product': 'Tracearr', + 'X-Plex-Version': '1.0.0', + 'X-Plex-Device': 'Server', + 'X-Plex-Platform': 'Node.js', + }; + + if (token) { + headers['X-Plex-Token'] = token; + } + + return headers; +} + +/** + * Helper to create Jellyfin-specific headers + */ +export function jellyfinHeaders(apiKey?: string): Record { + const headers: Record = { + 'Accept': 'application/json', + }; + + if (apiKey) { + headers['X-Emby-Token'] = apiKey; + } + + return headers; +} + +/** + * Helper to create Emby-specific headers + * Note: Emby uses the same X-Emby-Token header as Jellyfin (Jellyfin forked from Emby) + */ +export function embyHeaders(apiKey?: string): Record { + const headers: Record = { + 'Accept': 'application/json', + }; + + if (apiKey) { + headers['X-Emby-Token'] = apiKey; + } + + return headers; +} diff --git a/apps/server/src/utils/jwt.ts b/apps/server/src/utils/jwt.ts new file mode 100644 index 0000000..c822a8d --- /dev/null +++ b/apps/server/src/utils/jwt.ts @@ -0,0 +1,61 @@ +/** + * Standalone JWT verification utility + * Used by WebSocket middleware where Fastify's jwt plugin isn't available + */ + +import jwt from 'jsonwebtoken'; +import type { AuthUser } from '@tracearr/shared'; + +export interface JwtVerifyResult { + valid: true; + user: AuthUser; +} + +export interface JwtVerifyError { + valid: false; + error: string; +} + +export type JwtVerifyResponse = JwtVerifyResult | JwtVerifyError; + +/** + * Verify a JWT token and extract the user payload + * @param token - JWT token string + * @returns Verification result with user data or error + */ +export function verifyJwt(token: string): JwtVerifyResponse { + const secret = process.env.JWT_SECRET; + + if (!secret) { + return { valid: false, error: 'JWT_SECRET not configured' }; + } + + try { + const payload = jwt.verify(token, secret, { + algorithms: ['HS256'], + }) as AuthUser; + + // Validate required fields + if (!payload.userId || !payload.username || !payload.role) { + return { valid: false, error: 'Invalid token payload' }; + } + + return { + valid: true, + user: { + userId: payload.userId, + username: payload.username, + role: payload.role, + serverIds: payload.serverIds ?? [], + }, + }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { valid: false, error: 'Token expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { valid: false, error: 'Invalid token' }; + } + return { valid: false, error: 'Token verification failed' }; + } +} diff --git a/apps/server/src/utils/parsing.ts b/apps/server/src/utils/parsing.ts new file mode 100644 index 0000000..6827ee9 --- /dev/null +++ b/apps/server/src/utils/parsing.ts @@ -0,0 +1,267 @@ +/** + * Safe Type Coercion Utilities + * + * Provides consistent, safe parsing of unknown API response data into typed values. + * Used across media server integrations (Plex, Jellyfin) to normalize responses. + * + * All functions handle null, undefined, and invalid inputs gracefully. + */ + +/** + * Safely convert unknown value to string + * + * @param val - Value to convert + * @param defaultVal - Default if val is null/undefined (default: '') + * + * @example + * parseString(response.Id) // "abc123" + * parseString(null) // "" + * parseString(undefined, 'unknown') // "unknown" + */ +export function parseString(val: unknown, defaultVal = ''): string { + if (val == null) return defaultVal; + return String(val); +} + +/** + * Safely convert unknown value to string or undefined + * Returns undefined if value is null/undefined, otherwise converts to string + * + * @example + * parseOptionalString(response.SeriesName) // "Breaking Bad" or undefined + */ +export function parseOptionalString(val: unknown): string | undefined { + if (val == null) return undefined; + return String(val); +} + +/** + * Safely convert unknown value to number + * + * @param val - Value to convert + * @param defaultVal - Default if val is null/undefined/NaN (default: 0) + * + * @example + * parseNumber(response.Duration) // 7200 + * parseNumber(null) // 0 + * parseNumber("invalid") // 0 + */ +export function parseNumber(val: unknown, defaultVal = 0): number { + if (val == null) return defaultVal; + const num = Number(val); + return isNaN(num) ? defaultVal : num; +} + +/** + * Safely convert unknown value to number or undefined + * Returns undefined if value is null/undefined/NaN + * + * @example + * parseOptionalNumber(response.SeasonNumber) // 2 or undefined + * parseOptionalNumber("invalid") // undefined + */ +export function parseOptionalNumber(val: unknown): number | undefined { + if (val == null) return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; +} + +/** + * Safely convert unknown value to boolean + * + * @param val - Value to convert + * @param defaultVal - Default if val is null/undefined (default: false) + * + * @example + * parseBoolean(response.IsPaused) // true + * parseBoolean(null) // false + * parseBoolean(1) // true + */ +export function parseBoolean(val: unknown, defaultVal = false): boolean { + if (val == null) return defaultVal; + return Boolean(val); +} + +/** + * Safely convert unknown value to boolean or undefined + * + * @example + * parseOptionalBoolean(response.IsAdmin) // true or undefined + */ +export function parseOptionalBoolean(val: unknown): boolean | undefined { + if (val == null) return undefined; + return Boolean(val); +} + +/** + * Safely parse an array from unknown value and map each element + * + * @param val - Value expected to be an array + * @param mapper - Function to transform each element + * + * @example + * parseArray(response.Sessions, (s) => ({ id: parseString(s.Id) })) + */ +export function parseArray( + val: unknown, + mapper: (item: unknown, index: number) => T +): T[] { + if (!Array.isArray(val)) return []; + return val.map(mapper); +} + +/** + * Safely parse an array, filtering out items that don't pass predicate + * + * @example + * parseFilteredArray( + * response.Sessions, + * (s) => s.NowPlayingItem != null, + * (s) => ({ id: parseString(s.Id) }) + * ) + */ +export function parseFilteredArray( + val: unknown, + predicate: (item: unknown) => boolean, + mapper: (item: unknown, index: number) => T +): T[] { + if (!Array.isArray(val)) return []; + return val.filter(predicate).map(mapper); +} + +/** + * Safely get a nested property from an unknown object + * + * @example + * const policy = getNestedObject(user, 'Policy'); + * const isAdmin = parseBoolean(policy?.IsAdministrator); + */ +export function getNestedObject( + val: unknown, + key: string +): Record | undefined { + if (val == null || typeof val !== 'object') return undefined; + const nested = (val as Record)[key]; + if (nested == null || typeof nested !== 'object') return undefined; + return nested as Record; +} + +/** + * Safely get a nested property value + * + * @example + * const isAdmin = getNestedValue(user, 'Policy', 'IsAdministrator'); + */ +export function getNestedValue( + val: unknown, + ...keys: string[] +): unknown { + let current: unknown = val; + for (const key of keys) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current; +} + +/** + * Parse a value that can be number or empty string (common in Tautulli API) + * Returns the number if valid, or null for empty string/invalid + * + * @example + * parseNumberOrEmpty(response.year) // 2024 or null (if "") + * parseNumberOrEmpty("") // null + * parseNumberOrEmpty(123) // 123 + */ +export function parseNumberOrEmpty(val: unknown): number | null { + if (val === '' || val == null) return null; + const num = Number(val); + return isNaN(num) ? null : num; +} + +/** + * Safely get first element of an array and access a property + * Useful for patterns like: (item.Media as Record[])?.[0]?.bitrate + * + * @example + * parseFirstArrayElement(item.Media, 'bitrate', 0) // number from first element + * parseFirstArrayElement(item.MediaSources, 'Bitrate') // undefined if not found + */ +export function parseFirstArrayElement( + val: unknown, + key: string, + defaultVal?: T +): T | undefined { + if (!Array.isArray(val) || val.length === 0) return defaultVal; + const first = val[0]; + if (first == null || typeof first !== 'object') return defaultVal; + const value = (first as Record)[key]; + return value !== undefined ? (value as T) : defaultVal; +} + +/** + * Parse string or return null (for nullable DB fields) + * Unlike parseOptionalString which returns undefined + * + * @example + * parseStringOrNull(response.LastLoginDate) // "2024-01-15" or null + */ +export function parseStringOrNull(val: unknown): string | null { + if (val == null) return null; + return String(val); +} + +/** + * Parse ISO date string to Date or null + * + * @example + * parseDate(response.LastLoginDate) // Date object or null + */ +export function parseDate(val: unknown): Date | null { + if (val == null) return null; + const str = String(val); + const date = new Date(str); + return isNaN(date.getTime()) ? null : date; +} + +/** + * Parse ISO date string to ISO string or null (for DB storage) + * + * @example + * parseDateString(response.LastLoginDate) // "2024-01-15T10:30:00.000Z" or null + */ +export function parseDateString(val: unknown): string | null { + const date = parseDate(val); + return date ? date.toISOString() : null; +} + +/** + * Object with all parsing functions for convenient destructuring + * + * @example + * import { parse } from './parsing.js'; + * + * const session = { + * id: parse.string(data.Id), + * duration: parse.number(data.Duration), + * isPaused: parse.boolean(data.IsPaused), + * seasonNumber: parse.optionalNumber(data.SeasonNumber), + * }; + */ +export const parse = { + string: parseString, + optionalString: parseOptionalString, + stringOrNull: parseStringOrNull, + number: parseNumber, + optionalNumber: parseOptionalNumber, + numberOrEmpty: parseNumberOrEmpty, + boolean: parseBoolean, + optionalBoolean: parseOptionalBoolean, + array: parseArray, + filteredArray: parseFilteredArray, + firstArrayElement: parseFirstArrayElement, + nested: getNestedObject, + nestedValue: getNestedValue, + date: parseDate, + dateString: parseDateString, +} as const; diff --git a/apps/server/src/utils/password.ts b/apps/server/src/utils/password.ts new file mode 100644 index 0000000..19eeec2 --- /dev/null +++ b/apps/server/src/utils/password.ts @@ -0,0 +1,24 @@ +/** + * Password hashing utilities using bcrypt + */ + +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 12; + +/** + * Hash a password using bcrypt + */ +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Verify a password against a bcrypt hash + */ +export async function verifyPassword( + password: string, + hash: string +): Promise { + return bcrypt.compare(password, hash); +} diff --git a/apps/server/src/utils/serverFiltering.ts b/apps/server/src/utils/serverFiltering.ts new file mode 100644 index 0000000..de98169 --- /dev/null +++ b/apps/server/src/utils/serverFiltering.ts @@ -0,0 +1,156 @@ +/** + * Server filtering utilities for multi-server access control + * + * These utilities help enforce server-level access control across API routes. + * Users can only see data from servers they have access to (listed in serverIds). + * Owners bypass server filtering and see all servers. + */ + +import { sql, inArray, eq, type SQL, type Column } from 'drizzle-orm'; +import type { AuthUser } from '@tracearr/shared'; + +/** + * Build a SQL condition for filtering by server access. + * + * @param authUser - The authenticated user with serverIds array + * @param serverIdColumn - The column to filter on (e.g., sessions.serverId) + * @returns SQL condition or undefined (owners see all) + * + * @example + * ```ts + * const serverCondition = buildServerAccessCondition(authUser, sessions.serverId); + * if (serverCondition) { + * conditions.push(serverCondition); + * } + * ``` + */ +export function buildServerAccessCondition( + authUser: AuthUser, + serverIdColumn: Column +): SQL | undefined { + // Owners see all servers + if (authUser.role === 'owner') { + return undefined; + } + + // No server access - return impossible condition + if (authUser.serverIds.length === 0) { + return sql`false`; + } + + // Single server - use equality for simpler query plan + if (authUser.serverIds.length === 1) { + return eq(serverIdColumn, authUser.serverIds[0]); + } + + // Multiple servers - use IN clause + return inArray(serverIdColumn, authUser.serverIds); +} + +/** + * Build a SQL condition for an explicit serverId filter parameter. + * Also validates that the user has access to the requested server. + * + * @param authUser - The authenticated user + * @param serverId - The requested server ID (from query params) + * @param serverIdColumn - The column to filter on + * @returns Object with condition and error (if access denied) + * + * @example + * ```ts + * const { condition, error } = buildServerFilterCondition(authUser, serverId, sessions.serverId); + * if (error) { + * return reply.forbidden(error); + * } + * if (condition) { + * conditions.push(condition); + * } + * ``` + */ +export function buildServerFilterCondition( + authUser: AuthUser, + serverId: string | undefined, + serverIdColumn: Column +): { condition: SQL | undefined; error: string | null } { + // If explicit serverId requested, validate access + if (serverId) { + if (authUser.role !== 'owner' && !authUser.serverIds.includes(serverId)) { + return { + condition: undefined, + error: 'You do not have access to this server', + }; + } + // User has access, filter to specific server + return { + condition: eq(serverIdColumn, serverId), + error: null, + }; + } + + // No explicit serverId - apply user's server access filter + return { + condition: buildServerAccessCondition(authUser, serverIdColumn), + error: null, + }; +} + +/** + * Filter an array of items by server access. + * Use this for post-query filtering when the query can't be easily modified. + * + * @param items - Array of items with serverId property + * @param authUser - The authenticated user + * @returns Filtered array containing only accessible items + * + * @example + * ```ts + * const allSessions = await cache.getActiveSessions(); + * const userSessions = filterByServerAccess(allSessions, authUser); + * ``` + */ +export function filterByServerAccess( + items: T[], + authUser: AuthUser +): T[] { + // Owners see all + if (authUser.role === 'owner') { + return items; + } + + // Filter by accessible servers + return items.filter((item) => authUser.serverIds.includes(item.serverId)); +} + +/** + * Check if user has access to a specific server. + * + * @param authUser - The authenticated user + * @param serverId - The server ID to check + * @returns true if user has access + */ +export function hasServerAccess(authUser: AuthUser, serverId: string): boolean { + return authUser.role === 'owner' || authUser.serverIds.includes(serverId); +} + +/** + * Validate server access and return error message if denied. + * Convenience function for route handlers. + * + * @param authUser - The authenticated user + * @param serverId - The server ID to validate + * @returns Error message or null if access granted + * + * @example + * ```ts + * const error = validateServerAccess(authUser, serverId); + * if (error) { + * return reply.forbidden(error); + * } + * ``` + */ +export function validateServerAccess(authUser: AuthUser, serverId: string): string | null { + if (hasServerAccess(authUser, serverId)) { + return null; + } + return 'You do not have access to this server'; +} diff --git a/apps/server/src/websocket/index.ts b/apps/server/src/websocket/index.ts new file mode 100644 index 0000000..2a20095 --- /dev/null +++ b/apps/server/src/websocket/index.ts @@ -0,0 +1,128 @@ +/** + * Socket.io WebSocket server setup + */ + +import type { Server as HttpServer } from 'http'; +import { Server } from 'socket.io'; +import type { Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import type { ServerToClientEvents, ClientToServerEvents, AuthUser } from '@tracearr/shared'; +import { WS_EVENTS } from '@tracearr/shared'; + +type TypedServer = Server; +type TypedSocket = Socket; + +interface SocketData { + user: AuthUser; +} + +let io: TypedServer | null = null; + +/** + * Verify JWT token for WebSocket connections + */ +function verifyToken(token: string): AuthUser { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT_SECRET not configured'); + } + + const decoded = jwt.verify(token, secret) as AuthUser; + return decoded; +} + +export function initializeWebSocket(httpServer: HttpServer): TypedServer { + io = new Server(httpServer, { + cors: { + origin: process.env.CORS_ORIGIN || true, + credentials: true, + }, + pingTimeout: 60000, + pingInterval: 25000, + }); + + // Authentication middleware + io.use((socket: TypedSocket, next) => { + try { + const token = socket.handshake.auth.token as string | undefined; + + if (!token) { + next(new Error('Authentication required')); + return; + } + + // Verify JWT and attach user to socket + const user = verifyToken(token); + (socket.data as SocketData).user = user; + + next(); + } catch (error) { + console.error('[WebSocket] Auth error:', error); + next(new Error('Authentication failed')); + } + }); + + io.on('connection', (socket: TypedSocket) => { + const user = (socket.data as SocketData).user; + console.log(`[WebSocket] Client connected: ${socket.id} (user: ${user?.username ?? 'unknown'})`); + + // Join user-specific room for targeted messages + if (user?.userId) { + void socket.join(`user:${user.userId}`); + } + + // Join server rooms for server-specific messages + if (user?.serverIds) { + for (const serverId of user.serverIds) { + void socket.join(`server:${serverId}`); + } + } + + // Auto-subscribe to sessions on connect + void socket.join('sessions'); + + // Handle session subscriptions + socket.on(WS_EVENTS.SUBSCRIBE_SESSIONS as 'subscribe:sessions', () => { + void socket.join('sessions'); + console.log(`[WebSocket] ${socket.id} subscribed to sessions`); + }); + + socket.on(WS_EVENTS.UNSUBSCRIBE_SESSIONS as 'unsubscribe:sessions', () => { + void socket.leave('sessions'); + console.log(`[WebSocket] ${socket.id} unsubscribed from sessions`); + }); + + socket.on('disconnect', (reason) => { + console.log(`[WebSocket] Client disconnected: ${socket.id}, reason: ${reason}`); + }); + }); + + console.log('[WebSocket] Server initialized'); + return io; +} + +export function getIO(): TypedServer { + if (!io) { + throw new Error('WebSocket server not initialized'); + } + return io; +} + +export function broadcastToSessions( + event: K, + ...args: Parameters +): void { + if (io) { + (io.to('sessions').emit as (event: K, ...args: Parameters) => void)(event, ...args); + } +} + +export function broadcastToServer( + serverId: string, + event: K, + ...args: Parameters +): void { + if (io) { + (io.to(`server:${serverId}`).emit as (event: K, ...args: Parameters) => void)(event, ...args); + } +} diff --git a/apps/server/test/integration/mobile.integration.test.ts b/apps/server/test/integration/mobile.integration.test.ts new file mode 100644 index 0000000..5bda708 --- /dev/null +++ b/apps/server/test/integration/mobile.integration.test.ts @@ -0,0 +1,975 @@ +/** + * Mobile Authentication Integration Tests + * + * Tests mobile pairing, token exchange, refresh, and session management + * against a real database. + * + * Run with: pnpm test:integration + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import jwt from '@fastify/jwt'; +import cookie from '@fastify/cookie'; +import sensible from '@fastify/sensible'; +import { createHash, randomBytes } from 'crypto'; +import { eq } from 'drizzle-orm'; +import type { Redis } from 'ioredis'; +import type { AuthUser } from '@tracearr/shared'; +import { db } from '../../src/db/client.js'; +import { users, servers, serverUsers, settings, mobileTokens, mobileSessions } from '../../src/db/schema.js'; +import { mobileRoutes } from '../../src/routes/mobile.js'; + +// Constants (matching mobile.ts) +const TOKEN_EXPIRY_MINUTES = 15; +const MOBILE_TOKEN_PREFIX = 'trr_mob_'; +const MOBILE_REFRESH_TTL = 90 * 24 * 60 * 60; // 90 days + +// Test helpers +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function generateTestMobileToken(): string { + const randomPart = randomBytes(32).toString('base64url'); + return `${MOBILE_TOKEN_PREFIX}${randomPart}`; +} + +// Create mock Redis for rate limiting +function createMockRedis() { + const store = new Map(); + const counters = new Map(); + + return { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { + store.set(key, value); + return 'OK'; + }), + setex: vi.fn(async (key: string, _seconds: number, value: string) => { + store.set(key, value); + return 'OK'; + }), + del: vi.fn(async (key: string) => { + store.delete(key); + return 1; + }), + incr: vi.fn(async (key: string) => { + const current = counters.get(key) ?? 0; + counters.set(key, current + 1); + return current + 1; + }), + expire: vi.fn(async () => 1), + ttl: vi.fn(async () => 300), + ping: vi.fn(async () => 'PONG'), + keys: vi.fn(async (pattern: string) => { + const prefix = pattern.replace('*', ''); + return Array.from(store.keys()).filter(k => k.startsWith(prefix)); + }), + eval: vi.fn(async () => 1), // Default: first attempt (not rate limited) + _store: store, + _counters: counters, + _reset: () => { + store.clear(); + counters.clear(); + }, + }; +} + +// Test data holder +interface TestData { + ownerId: string; + serverId: string; + serverUserId: string; +} + +// Create test Fastify app with mobile routes +async function createMobileTestApp(): Promise { + const app = Fastify({ + logger: false, + }); + + // Register essential plugins + await app.register(sensible); + await app.register(cookie, { secret: 'test-cookie-secret-32-chars-long!' }); + await app.register(jwt, { + secret: process.env.JWT_SECRET ?? 'test-jwt-secret-must-be-32-chars-min', + sign: { algorithm: 'HS256' }, + }); + + // Add mock Redis + const mockRedis = createMockRedis(); + app.decorate('redis', mockRedis as unknown as Redis); + + // Add authenticate decorator + app.decorate('authenticate', async function (request: any, reply: any) { + try { + await request.jwtVerify(); + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Add requireOwner decorator + app.decorate('requireOwner', async function (request: any, reply: any) { + try { + await request.jwtVerify(); + if (request.user.role !== 'owner') { + reply.forbidden('Owner access required'); + } + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Add requireMobile decorator - validates token was issued for mobile app + app.decorate('requireMobile', async function (request: any, reply: any) { + try { + await request.jwtVerify(); + if (!request.user.mobile) { + reply.forbidden('Mobile access token required'); + } + } catch { + reply.unauthorized('Invalid or expired token'); + } + }); + + // Register mobile routes (same prefix as server) + await app.register(mobileRoutes, { prefix: '/api/v1/mobile' }); + + return app; +} + +// Seed test data +async function seedTestData(): Promise { + // Create owner user + const [user] = await db + .insert(users) + .values({ + username: 'testowner', + name: 'Test Owner', + role: 'owner', + aggregateTrustScore: 100, + }) + .returning(); + + // Create server + const [server] = await db + .insert(servers) + .values({ + name: 'Test Plex Server', + type: 'plex', + url: 'http://localhost:32400', + token: 'test-token-encrypted', + }) + .returning(); + + // Create server_user + const [serverUser] = await db + .insert(serverUsers) + .values({ + userId: user.id, + serverId: server.id, + externalId: 'plex-user-1', + username: 'testowner', + isServerAdmin: true, + trustScore: 100, + }) + .returning(); + + // Ensure settings row exists with mobile enabled + await db + .insert(settings) + .values({ id: 1, mobileEnabled: true }) + .onConflictDoUpdate({ + target: settings.id, + set: { mobileEnabled: true }, + }); + + return { + ownerId: user.id, + serverId: server.id, + serverUserId: serverUser.id, + }; +} + +// Clean up test data +async function cleanupTestData(): Promise { + // Clean in reverse order of dependencies + await db.delete(mobileSessions); + await db.delete(mobileTokens); + await db.delete(serverUsers); + await db.delete(servers); + await db.delete(users); +} + +// Generate owner JWT token +function generateOwnerToken(app: FastifyInstance, testData: TestData): string { + return app.jwt.sign( + { + userId: testData.ownerId, + username: 'testowner', + role: 'owner', + serverIds: [testData.serverId], + } as AuthUser, + { expiresIn: '1h' } + ); +} + +describe('Mobile Authentication Integration Tests', () => { + let app: FastifyInstance; + let testData: TestData; + + beforeAll(async () => { + app = await createMobileTestApp(); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupTestData(); + testData = await seedTestData(); + // Reset mock Redis + (app.redis as any)._reset(); + }); + + describe('POST /api/v1/mobile/pair-token - Generate Pairing Token', () => { + it('should generate a valid pairing token for owner', async () => { + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.token).toBeDefined(); + expect(body.token).toMatch(/^trr_mob_/); + expect(body.expiresAt).toBeDefined(); + + // Verify token was stored in database + const tokenHash = hashToken(body.token); + const [storedToken] = await db + .select() + .from(mobileTokens) + .where(eq(mobileTokens.tokenHash, tokenHash)); + + expect(storedToken).toBeDefined(); + expect(storedToken.createdBy).toBe(testData.ownerId); + expect(storedToken.usedAt).toBeNull(); + }); + + it('should reject non-owner users', async () => { + // Create a viewer token + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('should reject unauthenticated requests', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject when mobile is disabled', async () => { + // Disable mobile + await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1)); + + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().message).toContain('Mobile access is not enabled'); + }); + + it('should not count expired tokens toward pending limit', async () => { + const ownerToken = generateOwnerToken(app, testData); + + // Insert an expired token - should not count toward limit + await db.insert(mobileTokens).values({ + tokenHash: 'expired-token-hash-1234567890abcdef1234567890abcdef', + expiresAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago + createdBy: testData.ownerId, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + + // Both tokens exist (expired tokens are not automatically cleaned up) + const tokens = await db.select().from(mobileTokens); + expect(tokens.length).toBe(2); + }); + + it('should enforce max pending tokens limit', async () => { + const ownerToken = generateOwnerToken(app, testData); + + // Create 3 pending tokens (MAX_PENDING_TOKENS) + for (let i = 0; i < 3; i++) { + await db.insert(mobileTokens).values({ + tokenHash: `pending-token-hash-${i}-abcdef1234567890abcdef1234567890`, + expiresAt: new Date(Date.now() + 1000 * 60 * 15), // 15 min from now + createdBy: testData.ownerId, + }); + } + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair-token', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().message).toContain('Maximum of 3 pending tokens'); + }); + }); + + describe('POST /api/v1/mobile/pair - Exchange Token for JWT', () => { + let validPairingToken: string; + + beforeEach(async () => { + // Generate a valid pairing token in the database + validPairingToken = generateTestMobileToken(); + await db.insert(mobileTokens).values({ + tokenHash: hashToken(validPairingToken), + expiresAt: new Date(Date.now() + 1000 * 60 * TOKEN_EXPIRY_MINUTES), + createdBy: testData.ownerId, + }); + }); + + it('should exchange valid token for JWT and refresh token', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'Test iPhone', + deviceId: 'device-12345', + platform: 'ios', + }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).toBeDefined(); + expect(body.server).toBeDefined(); + expect(body.user).toBeDefined(); + expect(body.user.username).toBe('testowner'); + + // Verify mobile session was created + const [session] = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, 'device-12345')); + + expect(session).toBeDefined(); + expect(session.deviceName).toBe('Test iPhone'); + expect(session.platform).toBe('ios'); + + // Verify pairing token was marked as used + const [usedToken] = await db + .select() + .from(mobileTokens) + .where(eq(mobileTokens.tokenHash, hashToken(validPairingToken))); + + expect(usedToken.usedAt).not.toBeNull(); + }); + + it('should reject expired pairing token', async () => { + // Create an expired token + const expiredToken = generateTestMobileToken(); + await db.insert(mobileTokens).values({ + tokenHash: hashToken(expiredToken), + expiresAt: new Date(Date.now() - 1000 * 60), // 1 minute ago + createdBy: testData.ownerId, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: expiredToken, + deviceName: 'Test iPhone', + deviceId: 'device-12345', + platform: 'ios', + }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('expired'); + }); + + it('should reject already-used pairing token', async () => { + // First, use the token + await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'First Device', + deviceId: 'device-first', + platform: 'ios', + }, + }); + + // Try to use it again + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'Second Device', + deviceId: 'device-second', + platform: 'android', + }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('already been used'); + }); + + it('should reject invalid token format', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: 'invalid-token', + deviceName: 'Test iPhone', + deviceId: 'device-12345', + platform: 'ios', + }, + }); + + // Token without correct prefix returns 401 unauthorized + expect(res.statusCode).toBe(401); + }); + + it('should allow pairing even when mobile is disabled (token was pre-generated)', async () => { + // Note: /pair doesn't check mobileEnabled - tokens can be used if they were + // generated before mobile was disabled. Disabling mobile only prevents NEW tokens. + await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1)); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'Test iPhone', + deviceId: 'device-12345', + platform: 'ios', + }, + }); + + // Pairing succeeds because the token was valid + expect(res.statusCode).toBe(200); + expect(res.json().accessToken).toBeDefined(); + }); + + it('should enforce max paired devices limit', async () => { + // Create 5 existing sessions (MAX_PAIRED_DEVICES) + for (let i = 0; i < 5; i++) { + await db.insert(mobileSessions).values({ + userId: testData.ownerId, + refreshTokenHash: `existing-refresh-hash-${i}-abcdef1234567890abc`, + deviceName: `Existing Device ${i}`, + deviceId: `existing-device-${i}`, + platform: 'ios', + }); + } + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'New Device', + deviceId: 'device-new', + platform: 'android', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().message).toContain('Maximum of 5 devices'); + }); + + it('should accept device secret for push encryption', async () => { + const deviceSecret = randomBytes(32).toString('base64'); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: validPairingToken, + deviceName: 'Test iPhone', + deviceId: 'device-12345', + platform: 'ios', + deviceSecret, + }, + }); + + expect(res.statusCode).toBe(200); + + // Verify device secret was stored + const [session] = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, 'device-12345')); + + expect(session.deviceSecret).toBe(deviceSecret); + }); + }); + + describe('POST /api/v1/mobile/refresh - Refresh JWT Token', () => { + let validRefreshToken: string; + let mobileJwt: string; + + beforeEach(async () => { + // Generate a pairing token and pair a device + const pairingToken = generateTestMobileToken(); + await db.insert(mobileTokens).values({ + tokenHash: hashToken(pairingToken), + expiresAt: new Date(Date.now() + 1000 * 60 * TOKEN_EXPIRY_MINUTES), + createdBy: testData.ownerId, + }); + + const pairRes = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/pair', + payload: { + token: pairingToken, + deviceName: 'Test Device', + deviceId: 'device-refresh-test', + platform: 'ios', + }, + }); + + const pairBody = pairRes.json(); + validRefreshToken = pairBody.refreshToken; + mobileJwt = pairBody.accessToken; + }); + + it('should refresh token and rotate refresh token', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/refresh', + payload: { + refreshToken: validRefreshToken, + }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).toBeDefined(); + + // New refresh token should be different (rotation) + expect(body.refreshToken).not.toBe(validRefreshToken); + + // Old refresh token should no longer work + const secondRes = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/refresh', + payload: { + refreshToken: validRefreshToken, + }, + }); + + expect(secondRes.statusCode).toBe(401); + }); + + it('should reject invalid refresh token', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/refresh', + payload: { + refreshToken: 'invalid-refresh-token', + }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().message).toContain('Invalid or expired refresh token'); + }); + + it('should allow refresh even when mobile is disabled', async () => { + // Note: /refresh doesn't check mobileEnabled - existing sessions continue working. + // Disabling mobile only prevents NEW tokens and revokes sessions when explicitly disabled. + await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1)); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/refresh', + payload: { + refreshToken: validRefreshToken, + }, + }); + + // Refresh succeeds because the session is still valid + expect(res.statusCode).toBe(200); + expect(res.json().accessToken).toBeDefined(); + }); + + it('should update lastSeenAt on refresh', async () => { + // Get the session before refresh + const [sessionBefore] = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, 'device-refresh-test')); + + const lastSeenBefore = sessionBefore.lastSeenAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 100)); + + await app.inject({ + method: 'POST', + url: '/api/v1/mobile/refresh', + payload: { + refreshToken: validRefreshToken, + }, + }); + + // Get the session after refresh + const [sessionAfter] = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.deviceId, 'device-refresh-test')); + + expect(sessionAfter.lastSeenAt.getTime()).toBeGreaterThan(lastSeenBefore.getTime()); + }); + }); + + describe('DELETE /api/v1/mobile/sessions/:id - Revoke Session', () => { + let sessionId: string; + + beforeEach(async () => { + // Create a mobile session + const [session] = await db + .insert(mobileSessions) + .values({ + userId: testData.ownerId, + refreshTokenHash: 'test-refresh-hash-for-deletion-1234567890abcdef', + deviceName: 'Device to Delete', + deviceId: 'device-to-delete', + platform: 'ios', + }) + .returning(); + + sessionId = session.id; + }); + + it('should revoke session as owner', async () => { + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/v1/mobile/sessions/${sessionId}`, + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + + // Verify session was deleted + const sessions = await db + .select() + .from(mobileSessions) + .where(eq(mobileSessions.id, sessionId)); + + expect(sessions.length).toBe(0); + }); + + it('should reject non-owner users', async () => { + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/v1/mobile/sessions/${sessionId}`, + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('should return 404 for non-existent session', async () => { + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/mobile/sessions/00000000-0000-0000-0000-000000000000', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('should reject unauthenticated requests', async () => { + const res = await app.inject({ + method: 'DELETE', + url: `/api/v1/mobile/sessions/${sessionId}`, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /api/v1/mobile/sessions - Revoke All Sessions', () => { + beforeEach(async () => { + // Create multiple mobile sessions + for (let i = 0; i < 3; i++) { + await db.insert(mobileSessions).values({ + userId: testData.ownerId, + refreshTokenHash: `bulk-delete-refresh-hash-${i}-abcdef1234567890`, + deviceName: `Device ${i}`, + deviceId: `device-bulk-${i}`, + platform: i % 2 === 0 ? 'ios' : 'android', + }); + } + }); + + it('should revoke all sessions as owner', async () => { + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/mobile/sessions', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.revokedCount).toBe(3); + + // Verify all sessions were deleted + const sessions = await db.select().from(mobileSessions); + expect(sessions.length).toBe(0); + }); + + it('should return success even with no sessions to revoke', async () => { + // Clean up all sessions first + await db.delete(mobileSessions); + + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/mobile/sessions', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().revokedCount).toBe(0); + }); + + it('should reject non-owner users', async () => { + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/mobile/sessions', + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + }); + + describe('GET /api/v1/mobile - Get Mobile Config', () => { + it('should return mobile config for owner', async () => { + // Create a session to verify it appears in config + await db.insert(mobileSessions).values({ + userId: testData.ownerId, + refreshTokenHash: 'config-test-refresh-hash-abcdef1234567890abcd', + deviceName: 'Config Test Device', + deviceId: 'device-config-test', + platform: 'ios', + expoPushToken: 'ExponentPushToken[test123]', + }); + + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/mobile', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.isEnabled).toBe(true); + expect(body.sessions).toBeDefined(); + expect(body.sessions.length).toBe(1); + expect(body.sessions[0].deviceName).toBe('Config Test Device'); + expect(body.sessions[0].platform).toBe('ios'); + expect(body.serverName).toBeDefined(); + expect(body.maxDevices).toBe(5); + }); + + it('should reject non-owner users', async () => { + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/mobile', + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + }); + + describe('POST /api/v1/mobile/enable - Enable Mobile Access', () => { + beforeEach(async () => { + // Ensure mobile is disabled + await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1)); + }); + + it('should enable mobile access as owner', async () => { + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/enable', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().isEnabled).toBe(true); + + // Verify in database + const [settingsRow] = await db.select().from(settings).where(eq(settings.id, 1)); + expect(settingsRow.mobileEnabled).toBe(true); + }); + + it('should reject non-owner users', async () => { + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/enable', + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + }); + + describe('POST /api/v1/mobile/disable - Disable Mobile Access', () => { + it('should disable mobile access and revoke all sessions', async () => { + // Create some sessions + for (let i = 0; i < 2; i++) { + await db.insert(mobileSessions).values({ + userId: testData.ownerId, + refreshTokenHash: `disable-test-refresh-hash-${i}-abcdef1234567`, + deviceName: `Device ${i}`, + deviceId: `device-disable-${i}`, + platform: 'ios', + }); + } + + const ownerToken = generateOwnerToken(app, testData); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/disable', + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + + // Verify mobile is disabled + const [settingsRow] = await db.select().from(settings).where(eq(settings.id, 1)); + expect(settingsRow.mobileEnabled).toBe(false); + + // Verify all sessions were revoked + const sessions = await db.select().from(mobileSessions); + expect(sessions.length).toBe(0); + }); + + it('should reject non-owner users', async () => { + const viewerToken = app.jwt.sign( + { + userId: testData.ownerId, + username: 'testviewer', + role: 'viewer', + serverIds: [], + } as AuthUser, + { expiresIn: '1h' } + ); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/mobile/disable', + headers: { Authorization: `Bearer ${viewerToken}` }, + }); + + expect(res.statusCode).toBe(403); + }); + }); +}); diff --git a/apps/server/test/integration/userService.integration.test.ts b/apps/server/test/integration/userService.integration.test.ts new file mode 100644 index 0000000..3a1eda9 --- /dev/null +++ b/apps/server/test/integration/userService.integration.test.ts @@ -0,0 +1,196 @@ +/** + * User Service Integration Tests + * + * Tests userService functions against a real database. + * Uses global integration test setup for database management. + * + * Run with: pnpm test:integration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { db } from '../../src/db/client.js'; +import { servers } from '../../src/db/schema.js'; +import { + batchSyncUsersFromMediaServer, + syncUserFromMediaServer, + getServerUsersByServer, + getServerUserByExternalId, +} from '../../src/services/userService.js'; +import type { MediaUser } from '../../src/services/userService.js'; + +describe('userService integration tests', () => { + let testServerId: string; + + // Create a fresh server before each test (after global reset) + beforeEach(async () => { + const [server] = await db + .insert(servers) + .values({ + name: 'Integration Test Server', + type: 'plex', + url: 'http://localhost:32400', + token: 'encrypted-test-token', + }) + .returning(); + + testServerId = server.id; + }); + + describe('syncUserFromMediaServer', () => { + it('should create a new user and server user when none exists', async () => { + const mediaUser: MediaUser = { + id: `ext-${randomUUID().slice(0, 8)}`, + username: 'newuser', + email: 'newuser@example.com', + thumb: 'https://example.com/thumb.jpg', + isAdmin: false, + }; + + const result = await syncUserFromMediaServer(testServerId, mediaUser); + + expect(result.created).toBe(true); + expect(result.serverUser.externalId).toBe(mediaUser.id); + expect(result.serverUser.username).toBe(mediaUser.username); + expect(result.user.username).toBe(mediaUser.username); + + // Verify in database + const dbServerUser = await getServerUserByExternalId(testServerId, mediaUser.id); + expect(dbServerUser).not.toBeNull(); + expect(dbServerUser?.username).toBe(mediaUser.username); + }); + + it('should update existing server user when already exists', async () => { + const externalId = `ext-${randomUUID().slice(0, 8)}`; + const mediaUser: MediaUser = { + id: externalId, + username: 'originalname', + isAdmin: false, + }; + + // First create + const createResult = await syncUserFromMediaServer(testServerId, mediaUser); + expect(createResult.created).toBe(true); + + // Then update with new username + const updatedMediaUser: MediaUser = { + id: externalId, + username: 'updatedname', + email: 'updated@example.com', + isAdmin: true, + }; + + const updateResult = await syncUserFromMediaServer(testServerId, updatedMediaUser); + expect(updateResult.created).toBe(false); + expect(updateResult.serverUser.username).toBe('updatedname'); + }); + }); + + describe('batchSyncUsersFromMediaServer', () => { + it('should return zeros for empty input', async () => { + const result = await batchSyncUsersFromMediaServer(testServerId, []); + + expect(result.added).toBe(0); + expect(result.updated).toBe(0); + }); + + it('should create multiple new users', async () => { + const mediaUsers: MediaUser[] = [ + { id: `batch-1-${randomUUID().slice(0, 8)}`, username: 'batchuser1', isAdmin: false }, + { id: `batch-2-${randomUUID().slice(0, 8)}`, username: 'batchuser2', isAdmin: false }, + { id: `batch-3-${randomUUID().slice(0, 8)}`, username: 'batchuser3', isAdmin: true }, + ]; + + const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers); + + expect(result.added).toBe(3); + expect(result.updated).toBe(0); + + // Verify all users exist in database + const serverUsersMap = await getServerUsersByServer(testServerId); + expect(serverUsersMap.size).toBe(3); + expect(serverUsersMap.get(mediaUsers[0].id)?.username).toBe('batchuser1'); + expect(serverUsersMap.get(mediaUsers[1].id)?.username).toBe('batchuser2'); + expect(serverUsersMap.get(mediaUsers[2].id)?.username).toBe('batchuser3'); + }); + + it('should handle mix of new and existing users', async () => { + // Create one user first + const existingExternalId = `existing-${randomUUID().slice(0, 8)}`; + await syncUserFromMediaServer(testServerId, { + id: existingExternalId, + username: 'existinguser', + isAdmin: false, + }); + + // Now batch sync with mix of existing and new + const mediaUsers: MediaUser[] = [ + { id: existingExternalId, username: 'existinguser-updated', isAdmin: false }, + { id: `new-1-${randomUUID().slice(0, 8)}`, username: 'newuser1', isAdmin: false }, + { id: `new-2-${randomUUID().slice(0, 8)}`, username: 'newuser2', isAdmin: false }, + ]; + + const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers); + + expect(result.added).toBe(2); + expect(result.updated).toBe(1); + + // Verify the existing user was updated + const updatedUser = await getServerUserByExternalId(testServerId, existingExternalId); + expect(updatedUser?.username).toBe('existinguser-updated'); + }); + + it('should handle all existing users (update only)', async () => { + // Create users first + const externalIds = [ + `preexist-1-${randomUUID().slice(0, 8)}`, + `preexist-2-${randomUUID().slice(0, 8)}`, + ]; + + for (const extId of externalIds) { + await syncUserFromMediaServer(testServerId, { + id: extId, + username: `user-${extId.slice(0, 8)}`, + isAdmin: false, + }); + } + + // Batch sync with updates only + const mediaUsers: MediaUser[] = externalIds.map((extId) => ({ + id: extId, + username: `updated-${extId.slice(0, 8)}`, + isAdmin: true, + })); + + const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers); + + expect(result.added).toBe(0); + expect(result.updated).toBe(2); + }); + }); + + describe('getServerUsersByServer', () => { + it('should return empty map for server with no users', async () => { + const result = await getServerUsersByServer(testServerId); + expect(result.size).toBe(0); + }); + + it('should return map of all server users keyed by externalId', async () => { + // Create some users + const mediaUsers: MediaUser[] = [ + { id: `map-1-${randomUUID().slice(0, 8)}`, username: 'mapuser1', isAdmin: false }, + { id: `map-2-${randomUUID().slice(0, 8)}`, username: 'mapuser2', isAdmin: true }, + ]; + + await batchSyncUsersFromMediaServer(testServerId, mediaUsers); + + const result = await getServerUsersByServer(testServerId); + + expect(result.size).toBe(2); + expect(result.has(mediaUsers[0].id)).toBe(true); + expect(result.has(mediaUsers[1].id)).toBe(true); + expect(result.get(mediaUsers[0].id)?.username).toBe('mapuser1'); + expect(result.get(mediaUsers[1].id)?.isServerAdmin).toBe(true); + }); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..98e3399 --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,61 @@ +/** + * Main Vitest Configuration + * + * Runs ALL tests when using `pnpm test` or `pnpm test:coverage`. + * For targeted test runs, use the group-specific configs: + * + * pnpm test:unit - Pure functions (utils, parsers, schemas) + * pnpm test:services - Business logic (services, jobs) + * pnpm test:routes - API endpoints (routes with mocked DB) + * pnpm test:security - Auth/authz behavior tests + * + * Integration tests use a separate config: + * pnpm test:integration - Real DB tests (vitest.integration.config.ts) + */ + +import { defineConfig, mergeConfig } from 'vitest/config'; +import { sharedConfig } from './vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + // Run all unit + security tests (excludes integration) + include: ['src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/*.integration.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary', 'html', 'lcov'], + reportsDirectory: './coverage', + include: [ + 'src/services/**/*.ts', + 'src/routes/**/*.ts', + 'src/jobs/**/*.ts', + 'src/utils/**/*.ts', + ], + exclude: [ + '**/*.test.ts', + '**/test/**', + // Type-only files with no executable code + '**/types.ts', + // Index files that only register routes (no business logic) + '**/routes/**/index.ts', + '**/routes/auth/index.ts', + '**/routes/stats/index.ts', + '**/routes/users/index.ts', + // HTTP client wrappers tested via integration tests + '**/services/mediaServer/plex/client.ts', + '**/services/mediaServer/plex/eventSource.ts', + '**/services/mediaServer/jellyfin/client.ts', + '**/services/mediaServer/emby/client.ts', + ], + thresholds: { + statements: 42, + branches: 36, + functions: 48, + lines: 42, + }, + }, + }, + }) +); diff --git a/apps/server/vitest.integration.config.ts b/apps/server/vitest.integration.config.ts new file mode 100644 index 0000000..e76dded --- /dev/null +++ b/apps/server/vitest.integration.config.ts @@ -0,0 +1,70 @@ +/** + * Integration Test Configuration + * + * Run with: pnpm test:integration + * + * Integration tests: + * - Located in: test/integration/*.integration.test.ts + * - Use a REAL database (TimescaleDB/PostgreSQL) for testing + * - Database is automatically set up, migrated, and cleaned between tests + * - Longer timeouts for database operations + * - Run separately from unit tests to keep CI fast + * + * Prerequisites: + * - Docker running with test database container + * - TEST_DATABASE_URL environment variable (or uses default) + */ + +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; + +export default defineConfig({ + test: { + name: 'integration', + globals: true, + environment: 'node', + include: ['test/integration/**/*.integration.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + setupFiles: ['./src/test/setup.integration.ts'], + testTimeout: 30000, // Longer timeout for database operations + hookTimeout: 30000, + clearMocks: true, + restoreMocks: true, + // Run tests sequentially to avoid database conflicts + // fileParallelism: false ensures test FILES run one at a time + // singleFork: true ensures all tests share the same process + fileParallelism: false, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + reporters: isCI ? ['default', 'github-actions'] : ['default'], + // Coverage for integration tests + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary'], + reportsDirectory: './coverage/integration', + include: [ + 'src/services/**/*.ts', + 'src/routes/**/*.ts', + 'src/jobs/**/*.ts', + ], + exclude: [ + '**/*.test.ts', + '**/test/**', + 'src/services/mediaServer/**/*.ts', + ], + }, + }, + resolve: { + alias: { + '@tracearr/shared': resolve(__dirname, '../../packages/shared/src'), + // Use built files for test-utils to handle .js extension imports properly + '@tracearr/test-utils': resolve(__dirname, '../../packages/test-utils/dist'), + }, + }, +}); diff --git a/apps/server/vitest.routes.config.ts b/apps/server/vitest.routes.config.ts new file mode 100644 index 0000000..fd6c24f --- /dev/null +++ b/apps/server/vitest.routes.config.ts @@ -0,0 +1,38 @@ +/** + * Routes Tests Configuration + * + * API endpoint tests with mocked database: + * - routes/__tests__/* (rules, violations) + * - routes/auth/__tests__/* (auth utilities) + * - routes/stats/__tests__/* (stats utilities) + * + * Run: pnpm test:routes + */ + +import { defineConfig, mergeConfig } from 'vitest/config'; +import { sharedConfig } from './vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + name: 'routes', + include: [ + 'src/routes/__tests__/*.test.ts', + 'src/routes/auth/__tests__/*.test.ts', + 'src/routes/stats/__tests__/*.test.ts', + ], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary'], + reportsDirectory: './coverage/routes', + include: ['src/routes/**/*.ts'], + exclude: [ + '**/*.test.ts', + '**/*.security.test.ts', + '**/test/**', + ], + }, + }, + }) +); diff --git a/apps/server/vitest.security.config.ts b/apps/server/vitest.security.config.ts new file mode 100644 index 0000000..9243ad2 --- /dev/null +++ b/apps/server/vitest.security.config.ts @@ -0,0 +1,28 @@ +/** + * Security Tests Configuration + * + * Authentication and authorization tests: + * - Token validation and bypass attempts + * - Privilege escalation prevention + * - Injection attack prevention + * - Role-based access control + * + * These tests verify security behavior, not implementation coverage. + * No coverage thresholds - security tests are pass/fail. + * + * Run: pnpm test:security + */ + +import { defineConfig, mergeConfig } from 'vitest/config'; +import { sharedConfig } from './vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + name: 'security', + include: ['src/**/*.security.test.ts'], + // No coverage for security tests - they test behavior, not implementation + }, + }) +); diff --git a/apps/server/vitest.services.config.ts b/apps/server/vitest.services.config.ts new file mode 100644 index 0000000..f3652f7 --- /dev/null +++ b/apps/server/vitest.services.config.ts @@ -0,0 +1,42 @@ +/** + * Services Tests Configuration + * + * Business logic and background job tests: + * - services/* (rules, cache, geoip, userService, tautulli) + * - jobs/* (aggregator, poller logic) + * + * May use mocks for external dependencies. + * + * Run: pnpm test:services + */ + +import { defineConfig, mergeConfig } from 'vitest/config'; +import { sharedConfig } from './vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + name: 'services', + include: [ + 'src/services/__tests__/*.test.ts', + 'src/jobs/__tests__/*.test.ts', + 'src/jobs/poller/__tests__/*.test.ts', + ], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary'], + reportsDirectory: './coverage/services', + include: [ + 'src/services/**/*.ts', + 'src/jobs/**/*.ts', + ], + exclude: [ + '**/*.test.ts', + '**/test/**', + 'src/services/mediaServer/**/*.ts', // Covered by unit tests + ], + }, + }, + }) +); diff --git a/apps/server/vitest.shared.ts b/apps/server/vitest.shared.ts new file mode 100644 index 0000000..515cc13 --- /dev/null +++ b/apps/server/vitest.shared.ts @@ -0,0 +1,31 @@ +/** + * Shared Vitest configuration + * + * Base settings used by all test group configs. + * Import and merge with group-specific settings. + */ + +import { resolve } from 'node:path'; +import type { UserConfig } from 'vitest/config'; + +const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; + +export const sharedConfig: UserConfig = { + test: { + globals: true, + environment: 'node', + setupFiles: ['./src/test/setup.ts'], + testTimeout: 10000, + hookTimeout: 10000, + clearMocks: true, + restoreMocks: true, + reporters: isCI ? ['default', 'github-actions'] : ['default'], + }, + resolve: { + alias: { + '@tracearr/shared': resolve(__dirname, '../../packages/shared/src'), + // Use built files for test-utils to handle .js extension imports properly + '@tracearr/test-utils': resolve(__dirname, '../../packages/test-utils/dist'), + }, + }, +}; diff --git a/apps/server/vitest.unit.config.ts b/apps/server/vitest.unit.config.ts new file mode 100644 index 0000000..2c0c515 --- /dev/null +++ b/apps/server/vitest.unit.config.ts @@ -0,0 +1,37 @@ +/** + * Unit Tests Configuration + * + * Fast, isolated tests for pure functions: + * - utils/* (crypto, jwt, password, parsing, http, errors) + * - schemas (Zod validation) + * - mediaServer parsers (Plex/Jellyfin response parsing) + * + * Run: pnpm test:unit + */ + +import { defineConfig, mergeConfig } from 'vitest/config'; +import { sharedConfig } from './vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + name: 'unit', + include: [ + 'src/utils/__tests__/*.test.ts', + 'src/test/schemas.test.ts', + 'src/services/mediaServer/__tests__/*.test.ts', + ], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary'], + reportsDirectory: './coverage/unit', + include: [ + 'src/utils/**/*.ts', + 'src/services/mediaServer/**/*.ts', + ], + exclude: ['**/*.test.ts', '**/test/**'], + }, + }, + }) +); diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..0b6231b --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..8aef645 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + Tracearr + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..cb970de --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tracearr/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@hookform/resolvers": "^5.0.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.1.15", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.60.0", + "@tanstack/react-table": "^8.21.0", + "@tracearr/shared": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "highcharts": "^12.0.0", + "highcharts-react-official": "^3.2.1", + "leaflet": "^1.9.4", + "lucide-react": "^0.500.0", + "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", + "react": "^19.0.0", + "react-day-picker": "^9.12.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.55.0", + "react-leaflet": "^5.0.0", + "react-leaflet-heatmap-layer-v3": "3.0.3-beta-1", + "react-router": "^7.0.0", + "socket.io-client": "^4.8.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/leaflet": "^1.9.16", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "postcss": "^8.5.0", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/web/public/apple-touch-icon-transparent.png b/apps/web/public/apple-touch-icon-transparent.png new file mode 100644 index 0000000..e28d9a9 Binary files /dev/null and b/apps/web/public/apple-touch-icon-transparent.png differ diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000..3306b4f Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-96x96.png b/apps/web/public/favicon-96x96.png new file mode 100644 index 0000000..7a92abe Binary files /dev/null and b/apps/web/public/favicon-96x96.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000..1880a43 Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..8bb5511 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/images/banner.png b/apps/web/public/images/banner.png new file mode 100644 index 0000000..da559eb Binary files /dev/null and b/apps/web/public/images/banner.png differ diff --git a/apps/web/public/images/servers/emby.png b/apps/web/public/images/servers/emby.png new file mode 100644 index 0000000..dfe7c23 Binary files /dev/null and b/apps/web/public/images/servers/emby.png differ diff --git a/apps/web/public/images/servers/jellyfin.png b/apps/web/public/images/servers/jellyfin.png new file mode 100644 index 0000000..6900130 Binary files /dev/null and b/apps/web/public/images/servers/jellyfin.png differ diff --git a/apps/web/public/images/servers/plex.png b/apps/web/public/images/servers/plex.png new file mode 100644 index 0000000..45f8400 Binary files /dev/null and b/apps/web/public/images/servers/plex.png differ diff --git a/apps/web/public/images/square-with-name.png b/apps/web/public/images/square-with-name.png new file mode 100644 index 0000000..8a1595d Binary files /dev/null and b/apps/web/public/images/square-with-name.png differ diff --git a/apps/web/public/images/square.png b/apps/web/public/images/square.png new file mode 100644 index 0000000..69ec783 Binary files /dev/null and b/apps/web/public/images/square.png differ diff --git a/apps/web/public/logo-transparent.png b/apps/web/public/logo-transparent.png new file mode 100644 index 0000000..cd743ac Binary files /dev/null and b/apps/web/public/logo-transparent.png differ diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest new file mode 100644 index 0000000..91f7bc0 --- /dev/null +++ b/apps/web/public/site.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "Tracearr", + "short_name": "Tracearr", + "description": "Streaming access manager for Plex and Jellyfin", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#0B1A2E", + "background_color": "#050A12", + "display": "standalone", + "start_url": "/", + "scope": "/" +} diff --git a/apps/web/public/web-app-manifest-192x192-transparent.png b/apps/web/public/web-app-manifest-192x192-transparent.png new file mode 100644 index 0000000..2b61daf Binary files /dev/null and b/apps/web/public/web-app-manifest-192x192-transparent.png differ diff --git a/apps/web/public/web-app-manifest-192x192.png b/apps/web/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..5e7927e Binary files /dev/null and b/apps/web/public/web-app-manifest-192x192.png differ diff --git a/apps/web/public/web-app-manifest-512x512-transparent.png b/apps/web/public/web-app-manifest-512x512-transparent.png new file mode 100644 index 0000000..1e9b782 Binary files /dev/null and b/apps/web/public/web-app-manifest-512x512-transparent.png differ diff --git a/apps/web/public/web-app-manifest-512x512.png b/apps/web/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..943d8e5 Binary files /dev/null and b/apps/web/public/web-app-manifest-512x512.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..9196b44 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,66 @@ +import { Routes, Route, Navigate } from 'react-router'; +import { Toaster } from '@/components/ui/sonner'; +import { Layout } from '@/components/layout/Layout'; +import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; +import { Login } from '@/pages/Login'; +import { PlexCallback } from '@/pages/PlexCallback'; +import { Setup } from '@/pages/Setup'; +import { Dashboard } from '@/pages/Dashboard'; +import { Map } from '@/pages/Map'; +import { StatsActivity, StatsLibrary, StatsUsers } from '@/pages/stats'; +import { Users } from '@/pages/Users'; +import { UserDetail } from '@/pages/UserDetail'; +import { Rules } from '@/pages/Rules'; +import { Violations } from '@/pages/Violations'; +import { Settings } from '@/pages/Settings'; +import { Debug } from '@/pages/Debug'; +import { NotFound } from '@/pages/NotFound'; + +export function App() { + return ( + <> + + {/* Public routes */} + } /> + } /> + } /> + + {/* Protected routes */} + + + + } + > + } /> + } /> + + {/* Stats routes */} + } /> + } /> + } /> + } /> + + {/* Other routes */} + } /> + } /> + } /> + } /> + } /> + + {/* Hidden debug page (owner only) */} + } /> + + {/* Legacy redirects */} + } /> + } /> + + } /> + + + + + ); +} diff --git a/apps/web/src/components/auth/PlexServerSelector.tsx b/apps/web/src/components/auth/PlexServerSelector.tsx new file mode 100644 index 0000000..79b5417 --- /dev/null +++ b/apps/web/src/components/auth/PlexServerSelector.tsx @@ -0,0 +1,354 @@ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents -- eslint can't resolve @tracearr/shared types but TS compiles fine */ +import { useState } from 'react'; +import { Monitor, Wifi, Globe, Check, X, Loader2, ChevronDown, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { PlexDiscoveredServer, PlexDiscoveredConnection } from '@tracearr/shared'; +import type { PlexServerInfo, PlexServerConnection } from '@/lib/api'; + +/** + * Props for PlexServerSelector component + * + * Two modes: + * 1. Discovery mode (Settings): servers with tested connections, uses recommendedUri + * 2. Signup mode (Login): servers without testing, user picks any connection + */ +export interface PlexServerSelectorProps { + /** + * Servers to display - can be either discovered (with testing) or basic (signup flow) + */ + servers: PlexDiscoveredServer[] | PlexServerInfo[]; + + /** + * Called when user selects a server + * @param serverUri - The selected connection URI + * @param serverName - The server name + * @param clientIdentifier - The server's unique identifier + */ + onSelect: (serverUri: string, serverName: string, clientIdentifier: string) => void; + + /** + * Whether a connection attempt is in progress + */ + connecting?: boolean; + + /** + * Name of server currently being connected to + */ + connectingToServer?: string | null; + + /** + * Called when user clicks cancel/back + */ + onCancel?: () => void; + + /** + * Show cancel button (default true) + */ + showCancel?: boolean; + + /** + * Additional className for the container + */ + className?: string; +} + +/** + * Type guard to check if a server has discovery info (tested connections) + */ +function isDiscoveredServer(server: PlexDiscoveredServer | PlexServerInfo): server is PlexDiscoveredServer { + return 'recommendedUri' in server; +} + +/** + * Type guard to check if a connection has test results + */ +function isDiscoveredConnection(conn: PlexDiscoveredConnection | PlexServerConnection): conn is PlexDiscoveredConnection { + return 'reachable' in conn; +} + +/** + * Format latency for display + */ +function formatLatency(ms: number | null): string { + if (ms === null) return ''; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * PlexServerSelector - Displays Plex servers grouped by server with connection options + * + * Used in two contexts: + * 1. Settings page: Shows tested connections with reachability status and auto-selects best + * 2. Login page: Shows all connections for user selection during signup + */ +export function PlexServerSelector({ + servers, + onSelect, + connecting = false, + connectingToServer = null, + onCancel, + showCancel = true, + className, +}: PlexServerSelectorProps) { + // Track which servers have expanded connection lists + const [expandedServers, setExpandedServers] = useState>(new Set()); + + const toggleExpanded = (clientIdentifier: string) => { + setExpandedServers(prev => { + const next = new Set(prev); + if (next.has(clientIdentifier)) { + next.delete(clientIdentifier); + } else { + next.add(clientIdentifier); + } + return next; + }); + }; + + const handleQuickConnect = (server: PlexDiscoveredServer | PlexServerInfo) => { + // For discovered servers, use recommended URI; for basic, use first connection + const uri = isDiscoveredServer(server) + ? server.recommendedUri + : server.connections[0]?.uri; + + if (!uri) return; + + onSelect(uri, server.name, server.clientIdentifier); + }; + + const handleConnectionSelect = ( + server: PlexDiscoveredServer | PlexServerInfo, + connection: PlexDiscoveredConnection | PlexServerConnection + ) => { + onSelect(connection.uri, server.name, server.clientIdentifier); + }; + + if (servers.length === 0) { + return ( +
+ +

No Plex servers found

+

Make sure you own at least one Plex server

+
+ ); + } + + return ( +
+ {servers.map((server) => { + const clientId = server.clientIdentifier; + const isExpanded = expandedServers.has(clientId); + const isDiscovered = isDiscoveredServer(server); + const hasRecommended = isDiscovered && server.recommendedUri; + const isConnecting = connectingToServer === server.name; + + // For discovered servers, find recommended connection + const recommendedConn = isDiscovered + ? server.connections.find(c => c.uri === server.recommendedUri) + : null; + + // Count reachable connections for discovered servers + const reachableCount = isDiscovered + ? server.connections.filter(c => c.reachable).length + : server.connections.length; + + return ( +
+ {/* Server Header */} +
+
+
+ +
+
+

{server.name}

+

+ {server.platform} • v{server.version} +

+
+
+ + {/* Quick Connect Button */} + {hasRecommended && ( + + )} + + {/* No recommended - need to select manually */} + {isDiscovered && !hasRecommended && ( + + No reachable connections + + )} +
+ + {/* Recommended Connection Preview (for discovered servers) */} + {recommendedConn && isDiscoveredConnection(recommendedConn) && ( +
+ + + {recommendedConn.local ? 'Local' : 'Remote'}: {recommendedConn.address}:{recommendedConn.port} + + {recommendedConn.latencyMs !== null && ( + + ({formatLatency(recommendedConn.latencyMs)}) + + )} +
+ )} + + {/* Connection Count & Expand Toggle */} + + + {/* Expanded Connection List */} + {isExpanded && ( +
+ {server.connections.map((conn) => { + const isDiscoveredConn = isDiscoveredConnection(conn); + const isReachable = isDiscoveredConn ? conn.reachable : true; + const isRecommended = isDiscovered && conn.uri === server.recommendedUri; + + return ( + + ); + })} +
+ )} + + {/* For non-discovered servers (signup flow), show simple connection buttons if no expand */} + {!isDiscovered && !isExpanded && ( +
+ {server.connections.slice(0, 2).map((conn) => ( + + ))} + {server.connections.length > 2 && ( + + )} +
+ )} +
+ ); + })} + + {/* Connecting status */} + {connectingToServer && ( +
+ + Connecting to {connectingToServer}... +
+ )} + + {/* Cancel button */} + {showCancel && onCancel && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/auth/ProtectedRoute.tsx b/apps/web/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..77e3cc7 --- /dev/null +++ b/apps/web/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,33 @@ +import { Navigate, useLocation } from 'react-router'; +import { Loader2 } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +/** + * Wraps routes that require authentication. + * Redirects to /login if user is not authenticated. + */ +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + // Show loading spinner while checking auth status + if (isLoading) { + return ( +
+ +
+ ); + } + + // Redirect to login if not authenticated + if (!isAuthenticated) { + // Save the attempted URL for redirecting after login + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/components/brand/Logo.tsx b/apps/web/src/components/brand/Logo.tsx new file mode 100644 index 0000000..bc1dc74 --- /dev/null +++ b/apps/web/src/components/brand/Logo.tsx @@ -0,0 +1,83 @@ +import { cn } from '@/lib/utils'; + +interface LogoProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; + showText?: boolean; + className?: string; +} + +const sizes = { + sm: { icon: 'h-6 w-6', text: 'text-lg' }, + md: { icon: 'h-8 w-8', text: 'text-xl' }, + lg: { icon: 'h-12 w-12', text: 'text-3xl' }, + xl: { icon: 'h-16 w-16', text: 'text-4xl' }, +}; + +export function Logo({ size = 'md', showText = true, className }: LogoProps) { + const { icon, text } = sizes[size]; + + return ( +
+ + {showText && ( + Tracearr + )} +
+ ); +} + +interface LogoIconProps { + className?: string; +} + +export function LogoIcon({ className }: LogoIconProps) { + return ( + + {/* Background shield shape */} + + {/* Inner shield */} + + {/* T-path stylized */} + + {/* Radar arcs */} + + + + + ); +} diff --git a/apps/web/src/components/charts/ConcurrentChart.tsx b/apps/web/src/components/charts/ConcurrentChart.tsx new file mode 100644 index 0000000..99e3ae3 --- /dev/null +++ b/apps/web/src/components/charts/ConcurrentChart.tsx @@ -0,0 +1,250 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface ConcurrentData { + hour: string; + total: number; + direct: number; + transcode: number; +} + +interface ConcurrentChartProps { + data: ConcurrentData[] | undefined; + isLoading?: boolean; + height?: number; + period?: 'day' | 'week' | 'month' | 'year' | 'all' | 'custom'; +} + +export function ConcurrentChart({ data, isLoading, height = 250, period = 'month' }: ConcurrentChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + // Find the peak total for highlighting + const maxValue = Math.max(...data.map((d) => d.total)); + + return { + chart: { + type: 'area', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: true, + align: 'right', + verticalAlign: 'top', + floating: true, + itemStyle: { + color: 'hsl(var(--muted-foreground))', + fontWeight: 'normal', + fontSize: '11px', + }, + itemHoverStyle: { + color: 'hsl(var(--foreground))', + }, + }, + xAxis: { + categories: data.map((d) => d.hour), + // Calculate appropriate number of labels based on period + // Week: 7 labels (one per day), Month: ~10, Year: 12 + tickPositions: (() => { + const numLabels = period === 'week' || period === 'day' ? 7 : period === 'month' ? 10 : 12; + const actualLabels = Math.min(numLabels, data.length); + return Array.from({ length: actualLabels }, (_, i) => + Math.floor(i * (data.length - 1) / (actualLabels - 1 || 1)) + ); + })(), + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + formatter: function () { + // this.value could be index (number) or category string depending on Highcharts version + const categories = this.axis.categories; + const categoryValue = typeof this.value === 'number' + ? categories[this.value] + : this.value; + if (!categoryValue) return ''; + const date = new Date(categoryValue); + if (isNaN(date.getTime())) return ''; + if (period === 'year') { + // Short month name for yearly view (Dec, Jan, Feb) + return date.toLocaleDateString('en-US', { month: 'short' }); + } + // M/D format for week/month views + return `${date.getMonth() + 1}/${date.getDate()}`; + }, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + gridLineColor: 'hsl(var(--border))', + min: 0, + allowDecimals: false, + plotLines: [ + { + value: maxValue, + color: 'hsl(var(--destructive))', + dashStyle: 'Dash', + width: 1, + }, + ], + }, + plotOptions: { + area: { + stacking: 'normal', + marker: { + enabled: false, + states: { + hover: { + enabled: true, + radius: 4, + }, + }, + }, + lineWidth: 2, + states: { + hover: { + lineWidth: 2, + }, + }, + threshold: null, + }, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + shared: true, + formatter: function () { + const points = this.points || []; + // With categories, this.x is the index. Use the category value from points[0].key + const categoryValue = points[0]?.key as string | undefined; + const date = categoryValue ? new Date(categoryValue) : null; + const dateStr = date && !isNaN(date.getTime()) + ? `${date.toLocaleDateString()} ${date.getHours()}:00` + : 'Unknown'; + let html = `${dateStr}`; + + // Calculate total from stacked values + let total = 0; + points.forEach((point) => { + total += point.y || 0; + html += `
${point.series.name}: ${point.y}`; + }); + html += `
Total: ${total}`; + + return html; + }, + }, + series: [ + { + type: 'area', + name: 'Direct Play', + data: data.map((d) => d.direct), + color: 'hsl(var(--chart-2))', + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, 'hsl(var(--chart-2) / 0.4)'], + [1, 'hsl(var(--chart-2) / 0.1)'], + ], + }, + }, + { + type: 'area', + name: 'Transcode', + data: data.map((d) => d.transcode), + color: 'hsl(var(--chart-4))', + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, 'hsl(var(--chart-4) / 0.4)'], + [1, 'hsl(var(--chart-4) / 0.1)'], + ], + }, + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + legend: { + floating: false, + align: 'center', + verticalAlign: 'bottom', + itemStyle: { + fontSize: '10px', + }, + }, + xAxis: { + labels: { + style: { + fontSize: '9px', + }, + }, + }, + yAxis: { + labels: { + style: { + fontSize: '9px', + }, + }, + }, + }, + }, + ], + }, + }; + }, [data, height, period]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No concurrent stream data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/DayOfWeekChart.tsx b/apps/web/src/components/charts/DayOfWeekChart.tsx new file mode 100644 index 0000000..68733de --- /dev/null +++ b/apps/web/src/components/charts/DayOfWeekChart.tsx @@ -0,0 +1,136 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface DayOfWeekData { + day: number; + name: string; + count: number; +} + +interface DayOfWeekChartProps { + data: DayOfWeekData[] | undefined; + isLoading?: boolean; + height?: number; +} + +export function DayOfWeekChart({ data, isLoading, height = 250 }: DayOfWeekChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + return { + chart: { + type: 'column', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: false, + }, + xAxis: { + categories: data.map((d) => d.name), + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + gridLineColor: 'hsl(var(--border))', + min: 0, + }, + plotOptions: { + column: { + borderRadius: 4, + color: 'hsl(var(--primary))', + states: { + hover: { + color: 'hsl(var(--primary) / 0.8)', + }, + }, + }, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + formatter: function () { + return `${this.x}
Plays: ${this.y}`; + }, + }, + series: [ + { + type: 'column', + name: 'Plays', + data: data.map((d) => d.count), + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + xAxis: { + labels: { + style: { + fontSize: '9px', + }, + }, + }, + }, + }, + ], + }, + }; + }, [data, height]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/HourOfDayChart.tsx b/apps/web/src/components/charts/HourOfDayChart.tsx new file mode 100644 index 0000000..ed6332d --- /dev/null +++ b/apps/web/src/components/charts/HourOfDayChart.tsx @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface HourOfDayData { + hour: number; + count: number; +} + +interface HourOfDayChartProps { + data: HourOfDayData[] | undefined; + isLoading?: boolean; + height?: number; +} + +export function HourOfDayChart({ data, isLoading, height = 250 }: HourOfDayChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + // Format hour labels (12am, 1am, ... 11pm) + const formatHour = (hour: number): string => { + if (hour === 0) return '12am'; + if (hour === 12) return '12pm'; + return hour < 12 ? `${hour}am` : `${hour - 12}pm`; + }; + + return { + chart: { + type: 'column', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: false, + }, + xAxis: { + categories: data.map((d) => formatHour(d.hour)), + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + fontSize: '10px', + }, + rotation: -45, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + gridLineColor: 'hsl(var(--border))', + min: 0, + }, + plotOptions: { + column: { + borderRadius: 2, + color: 'hsl(var(--chart-2))', + pointPadding: 0.1, + groupPadding: 0.1, + states: { + hover: { + color: 'hsl(var(--chart-2) / 0.8)', + }, + }, + }, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + formatter: function () { + return `${this.x}
Plays: ${this.y}`; + }, + }, + series: [ + { + type: 'column', + name: 'Plays', + data: data.map((d) => d.count), + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + xAxis: { + labels: { + style: { + fontSize: '8px', + }, + rotation: -60, + }, + }, + }, + }, + ], + }, + }; + }, [data, height]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/PlatformChart.tsx b/apps/web/src/components/charts/PlatformChart.tsx new file mode 100644 index 0000000..7fdebb6 --- /dev/null +++ b/apps/web/src/components/charts/PlatformChart.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface PlatformData { + name: string; + count: number; +} + +interface PlatformChartProps { + data: PlatformData[] | undefined; + isLoading?: boolean; + height?: number; +} + +const COLORS = [ + 'hsl(var(--primary))', + 'hsl(221, 83%, 53%)', // Blue + 'hsl(142, 76%, 36%)', // Green + 'hsl(38, 92%, 50%)', // Yellow/Orange + 'hsl(262, 83%, 58%)', // Purple + 'hsl(340, 82%, 52%)', // Pink +]; + +export function PlatformChart({ data, isLoading, height = 200 }: PlatformChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + return { + chart: { + type: 'pie', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + pointFormat: '{point.y} plays ({point.percentage:.1f}%)', + }, + plotOptions: { + pie: { + innerSize: '60%', + borderWidth: 0, + dataLabels: { + enabled: false, + }, + showInLegend: true, + colors: COLORS, + }, + }, + legend: { + align: 'right', + verticalAlign: 'middle', + layout: 'vertical', + itemStyle: { + color: 'hsl(var(--foreground))', + }, + itemHoverStyle: { + color: 'hsl(var(--primary))', + }, + }, + series: [ + { + type: 'pie', + name: 'Platform', + data: data.map((d, i) => ({ + name: d.name, + y: d.count, + color: COLORS[i % COLORS.length], + })), + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + legend: { + align: 'center', + verticalAlign: 'bottom', + layout: 'horizontal', + itemStyle: { + fontSize: '10px', + }, + }, + }, + }, + ], + }, + }; + }, [data, height]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No platform data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/PlaysChart.tsx b/apps/web/src/components/charts/PlaysChart.tsx new file mode 100644 index 0000000..d18b8c0 --- /dev/null +++ b/apps/web/src/components/charts/PlaysChart.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import type { PlayStats } from '@tracearr/shared'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface PlaysChartProps { + data: PlayStats[] | undefined; + isLoading?: boolean; + height?: number; + period?: 'day' | 'week' | 'month' | 'year' | 'all' | 'custom'; +} + +export function PlaysChart({ data, isLoading, height = 200, period = 'month' }: PlaysChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + return { + chart: { + type: 'area', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: false, + }, + xAxis: { + categories: data.map((d) => d.date), + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + formatter: function () { + // this.value could be index (number) or category string depending on Highcharts version + const categories = this.axis.categories; + const categoryValue = typeof this.value === 'number' + ? categories[this.value] + : this.value; + if (!categoryValue) return ''; + const date = new Date(categoryValue); + if (isNaN(date.getTime())) return ''; + if (period === 'year') { + // Short month name for yearly view (Dec, Jan, Feb) + return date.toLocaleDateString('en-US', { month: 'short' }); + } + // M/D format for week/month views + return `${date.getMonth() + 1}/${date.getDate()}`; + }, + step: Math.ceil(data.length / 12), // Show ~12 labels + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + gridLineColor: 'hsl(var(--border))', + min: 0, + }, + plotOptions: { + area: { + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, 'hsl(var(--primary) / 0.3)'], + [1, 'hsl(var(--primary) / 0.05)'], + ], + }, + marker: { + enabled: false, + states: { + hover: { + enabled: true, + radius: 4, + }, + }, + }, + lineWidth: 2, + lineColor: 'hsl(var(--primary))', + states: { + hover: { + lineWidth: 2, + }, + }, + threshold: null, + }, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + formatter: function () { + // With categories, this.x is the index. Use this.point.category for the actual value + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const categoryValue = (this as any).point?.category as string | undefined; + const date = categoryValue ? new Date(categoryValue) : null; + const dateStr = date && !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : 'Unknown'; + return `${dateStr}
Plays: ${this.y}`; + }, + }, + series: [ + { + type: 'area', + name: 'Plays', + data: data.map((d) => d.count), + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + xAxis: { + labels: { + style: { + fontSize: '9px', + }, + step: Math.ceil(data.length / 6), + }, + }, + yAxis: { + labels: { + style: { + fontSize: '9px', + }, + }, + }, + }, + }, + ], + }, + }; + }, [data, height, period]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No play data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/QualityChart.tsx b/apps/web/src/components/charts/QualityChart.tsx new file mode 100644 index 0000000..4f2cf3f --- /dev/null +++ b/apps/web/src/components/charts/QualityChart.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface QualityData { + directPlay: number; + transcode: number; + total: number; + directPlayPercent: number; + transcodePercent: number; +} + +interface QualityChartProps { + data: QualityData | undefined; + isLoading?: boolean; + height?: number; +} + +const COLORS = { + directPlay: 'hsl(142, 76%, 36%)', // Green + transcode: 'hsl(38, 92%, 50%)', // Orange +}; + +export function QualityChart({ data, isLoading, height = 250 }: QualityChartProps) { + const options = useMemo(() => { + if (!data || data.total === 0) { + return {}; + } + + return { + chart: { + type: 'pie', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + pointFormat: '{point.y} plays ({point.percentage:.1f}%)', + }, + plotOptions: { + pie: { + innerSize: '60%', + borderWidth: 0, + dataLabels: { + enabled: false, + }, + showInLegend: true, + }, + }, + legend: { + align: 'right', + verticalAlign: 'middle', + layout: 'vertical', + itemStyle: { + color: 'hsl(var(--foreground))', + }, + itemHoverStyle: { + color: 'hsl(var(--primary))', + }, + }, + series: [ + { + type: 'pie', + name: 'Quality', + data: [ + { + name: 'Direct Play', + y: data.directPlay, + color: COLORS.directPlay, + }, + { + name: 'Transcode', + y: data.transcode, + color: COLORS.transcode, + }, + ], + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + legend: { + align: 'center', + verticalAlign: 'bottom', + layout: 'horizontal', + itemStyle: { + fontSize: '10px', + }, + }, + }, + }, + ], + }, + }; + }, [data, height]); + + if (isLoading) { + return ; + } + + if (!data || data.total === 0) { + return ( +
+ No quality data available +
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/charts/ServerResourceCharts.tsx b/apps/web/src/components/charts/ServerResourceCharts.tsx new file mode 100644 index 0000000..219b35a --- /dev/null +++ b/apps/web/src/components/charts/ServerResourceCharts.tsx @@ -0,0 +1,350 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import type { ServerResourceDataPoint } from '@tracearr/shared'; +import { ChartSkeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Cpu, MemoryStick } from 'lucide-react'; + +// Colors matching Plex's style +const COLORS = { + process: '#00b4e4', // Plex-style cyan for "Plex Media Server" + system: '#cc7b9f', // Pink/purple for "System" + processGradientStart: 'rgba(0, 180, 228, 0.3)', + processGradientEnd: 'rgba(0, 180, 228, 0.05)', + systemGradientStart: 'rgba(204, 123, 159, 0.3)', + systemGradientEnd: 'rgba(204, 123, 159, 0.05)', +}; + +interface ServerResourceChartsProps { + data: ServerResourceDataPoint[] | undefined; + isLoading?: boolean; + averages?: { + hostCpu: number; + processCpu: number; + hostMemory: number; + processMemory: number; + } | null; +} + +interface ResourceChartProps { + title: string; + icon: React.ReactNode; + data: ServerResourceDataPoint[] | undefined; + processKey: 'processCpuUtilization' | 'processMemoryUtilization'; + hostKey: 'hostCpuUtilization' | 'hostMemoryUtilization'; + processAvg?: number; + hostAvg?: number; + isLoading?: boolean; +} + +// Static x-axis labels (7 ticks at 20s intervals over 2 minutes) +const X_LABELS: Record = { + [-120]: '2m', + [-100]: '1m 40s', + [-80]: '1m 20s', + [-60]: '1m', + [-40]: '40s', + [-20]: '20s', + [0]: 'NOW', +}; + +/** + * Single resource chart (CPU or RAM) + */ +function ResourceChart({ + title, + icon, + data, + processKey, + hostKey, + processAvg, + hostAvg, + isLoading, +}: ResourceChartProps) { + const chartOptions = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + // Map data points to x positions in -120 to 0 range + // Data is sorted oldest first, spread across the 2-minute window + const processData: [number, number][] = []; + const hostData: [number, number][] = []; + + const n = data.length; + for (let i = 0; i < n; i++) { + const point = data[i]; + if (!point) continue; + // Spread points from -120 (oldest) to 0 (newest) + const x = n === 1 ? 0 : -120 + (i * 120) / (n - 1); + processData.push([x, point[processKey]]); + hostData.push([x, point[hostKey]]); + } + + // Calculate dynamic Y-axis max (round up to nearest 10, min 20) + const allValues = [...processData, ...hostData].map(([, y]) => y); + const maxValue = Math.max(...allValues, 0); + const yMax = Math.max(20, Math.ceil(maxValue / 10) * 10); + + return { + chart: { + type: 'area', + height: 180, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + spacing: [10, 10, 15, 10], + reflow: true, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: true, + align: 'left', + verticalAlign: 'top', + floating: false, + itemStyle: { + color: 'hsl(var(--muted-foreground))', + fontWeight: 'normal', + fontSize: '11px', + }, + itemHoverStyle: { + color: 'hsl(var(--foreground))', + }, + }, + xAxis: { + type: 'linear', + min: -120, + max: 0, + tickInterval: 20, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + fontSize: '10px', + }, + formatter: function () { + return X_LABELS[this.value as number] || ''; + }, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + fontSize: '10px', + }, + format: '{value}%', + }, + gridLineColor: 'hsl(var(--border) / 0.5)', + min: 0, + max: yMax, + tickInterval: yMax <= 20 ? 5 : 10, + }, + plotOptions: { + area: { + marker: { + enabled: false, + states: { + hover: { + enabled: true, + radius: 3, + }, + }, + }, + lineWidth: 2, + states: { + hover: { + lineWidth: 2, + }, + }, + threshold: null, + connectNulls: false, // Don't connect across null values + }, + }, + tooltip: { + shared: true, + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + fontSize: '11px', + }, + formatter: function () { + const points = this.points || []; + let html = ''; + for (const point of points) { + if (point.y !== null) { + const color = point.series.color; + html += ` ${point.series.name}: ${Math.round(point.y as number)}%
`; + } + } + return html; + }, + }, + series: [ + { + type: 'area', + name: 'Plex Media Server', + data: processData, + color: COLORS.process, + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, COLORS.processGradientStart], + [1, COLORS.processGradientEnd], + ], + }, + }, + { + type: 'area', + name: 'System', + data: hostData, + color: COLORS.system, + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, COLORS.systemGradientStart], + [1, COLORS.systemGradientEnd], + ], + }, + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 400, + }, + chartOptions: { + legend: { + align: 'center', + layout: 'horizontal', + itemStyle: { + fontSize: '10px', + }, + }, + xAxis: { + tickInterval: 40, + labels: { + style: { + fontSize: '9px', + }, + }, + }, + }, + }, + ], + }, + }; + }, [data, processKey, hostKey]); + + if (isLoading) { + return ( + + + + {icon} + {title} + + + + + + + ); + } + + if (!data || data.length === 0) { + return ( + + + + {icon} + {title} + + + +
+ No data available +
+
+
+ ); + } + + return ( + + + + + {icon} + {title} + + + + + + {/* Averages row */} +
+ + Avg:{' '} + {processAvg ?? '—'}% + + + Avg:{' '} + {hostAvg ?? '—'}% + +
+
+
+ ); +} + +/** + * Server resource monitoring charts (CPU + RAM) + * Displays real-time server resource utilization matching Plex's dashboard style + */ +export function ServerResourceCharts({ data, isLoading, averages }: ServerResourceChartsProps) { + return ( +
+ } + data={data} + processKey="processCpuUtilization" + hostKey="hostCpuUtilization" + processAvg={averages?.processCpu} + hostAvg={averages?.hostCpu} + isLoading={isLoading} + /> + } + data={data} + processKey="processMemoryUtilization" + hostKey="hostMemoryUtilization" + processAvg={averages?.processMemory} + hostAvg={averages?.hostMemory} + isLoading={isLoading} + /> +
+ ); +} diff --git a/apps/web/src/components/charts/TopListChart.tsx b/apps/web/src/components/charts/TopListChart.tsx new file mode 100644 index 0000000..fa30ffa --- /dev/null +++ b/apps/web/src/components/charts/TopListChart.tsx @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import { ChartSkeleton } from '@/components/ui/skeleton'; + +interface TopListItem { + name: string; + value: number; + subtitle?: string; +} + +interface TopListChartProps { + data: TopListItem[] | undefined; + isLoading?: boolean; + height?: number; + valueLabel?: string; + color?: string; +} + +export function TopListChart({ + data, + isLoading, + height = 250, + valueLabel = 'Value', + color = 'hsl(var(--primary))', +}: TopListChartProps) { + const options = useMemo(() => { + if (!data || data.length === 0) { + return {}; + } + + // Take top 10 + const top10 = data.slice(0, 10); + + return { + chart: { + type: 'bar', + height, + backgroundColor: 'transparent', + style: { + fontFamily: 'inherit', + }, + }, + title: { + text: undefined, + }, + credits: { + enabled: false, + }, + legend: { + enabled: false, + }, + xAxis: { + categories: top10.map((d) => d.name), + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + fontSize: '11px', + }, + }, + lineColor: 'hsl(var(--border))', + tickColor: 'hsl(var(--border))', + }, + yAxis: { + title: { + text: undefined, + }, + labels: { + style: { + color: 'hsl(var(--muted-foreground))', + }, + }, + gridLineColor: 'hsl(var(--border))', + min: 0, + }, + plotOptions: { + bar: { + borderRadius: 4, + color: color, + dataLabels: { + enabled: true, + style: { + color: 'hsl(var(--muted-foreground))', + textOutline: 'none', + fontWeight: 'normal', + }, + }, + states: { + hover: { + color: color.replace(')', ' / 0.8)').replace('hsl', 'hsl'), + }, + }, + }, + }, + tooltip: { + backgroundColor: 'hsl(var(--popover))', + borderColor: 'hsl(var(--border))', + style: { + color: 'hsl(var(--popover-foreground))', + }, + formatter: function () { + const xValue = String(this.x); + const item = top10.find((d) => d.name === xValue); + let tooltip = `${xValue}
${valueLabel}: ${this.y}`; + if (item?.subtitle) { + tooltip += `
${item.subtitle}`; + } + return tooltip; + }, + }, + series: [ + { + type: 'bar', + name: valueLabel, + data: top10.map((d) => d.value), + }, + ], + }; + }, [data, height, valueLabel, color]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+ No data available +
+ ); + } + + return ; +} diff --git a/apps/web/src/components/charts/index.ts b/apps/web/src/components/charts/index.ts new file mode 100644 index 0000000..2aaf81a --- /dev/null +++ b/apps/web/src/components/charts/index.ts @@ -0,0 +1,7 @@ +export { PlaysChart } from './PlaysChart'; +export { PlatformChart } from './PlatformChart'; +export { DayOfWeekChart } from './DayOfWeekChart'; +export { HourOfDayChart } from './HourOfDayChart'; +export { QualityChart } from './QualityChart'; +export { TopListChart } from './TopListChart'; +export { ConcurrentChart } from './ConcurrentChart'; diff --git a/apps/web/src/components/icons/MediaServerIcon.tsx b/apps/web/src/components/icons/MediaServerIcon.tsx new file mode 100644 index 0000000..528adf3 --- /dev/null +++ b/apps/web/src/components/icons/MediaServerIcon.tsx @@ -0,0 +1,48 @@ +import { Server } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Import server type from shared +import type { ServerType } from '@tracearr/shared'; + +interface MediaServerIconProps { + /** Server type: 'plex', 'jellyfin', or 'emby' */ + type: ServerType; + /** CSS class name for sizing (e.g., "h-4 w-4") */ + className?: string; + /** Alt text for accessibility */ + alt?: string; +} + +/** + * Displays the appropriate icon for a media server type. + * Falls back to generic Server icon if type is unknown. + */ +export function MediaServerIcon({ type, className, alt }: MediaServerIconProps) { + const iconPath = getIconPath(type); + + if (!iconPath) { + // Fallback to generic server icon + return ; + } + + return ( + {alt + ); +} + +function getIconPath(type: ServerType): string | null { + switch (type) { + case 'plex': + return '/images/servers/plex.png'; + case 'jellyfin': + return '/images/servers/jellyfin.png'; + case 'emby': + return '/images/servers/emby.png'; + default: + return null; + } +} diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx new file mode 100644 index 0000000..2165eba --- /dev/null +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -0,0 +1,116 @@ +import { NavLink, useLocation } from 'react-router'; +import { ChevronRight } from 'lucide-react'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Logo } from '@/components/brand/Logo'; +import { ServerSelector } from './ServerSelector'; +import { navigation, isNavGroup, type NavItem, type NavGroup } from './nav-data'; +import { cn } from '@/lib/utils'; + +function NavMenuItem({ item }: { item: NavItem }) { + const { setOpenMobile } = useSidebar(); + + return ( + + + setOpenMobile(false)} + className={({ isActive }) => + cn(isActive && 'bg-sidebar-accent text-sidebar-accent-foreground') + } + > + + {item.name} + + + + ); +} + +function NavMenuGroup({ group }: { group: NavGroup }) { + const location = useLocation(); + const { setOpenMobile } = useSidebar(); + const isActive = group.children.some((child) => + location.pathname.startsWith(child.href) + ); + + return ( + + + + + + {group.name} + + + + + + {group.children.map((child) => ( + + + setOpenMobile(false)} + className={({ isActive }) => + cn(isActive && 'bg-sidebar-accent text-sidebar-accent-foreground') + } + > + + {child.name} + + + + ))} + + + + + ); +} + +export function AppSidebar() { + return ( + + +
+ +
+ +
+ + + + + {navigation.map((entry) => { + if (isNavGroup(entry)) { + return ; + } + return ; + })} + + + + +
+ ); +} diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx new file mode 100644 index 0000000..68267d1 --- /dev/null +++ b/apps/web/src/components/layout/Header.tsx @@ -0,0 +1,76 @@ +import { LogOut, Settings } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { ModeToggle } from '@/components/ui/mode-toggle'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/hooks/useAuth'; + +export function Header() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + }; + + const initials = user?.username + ? user.username.slice(0, 2).toUpperCase() + : 'U'; + + return ( +
+ + + +
+ +
+ + + + + + + +
+

{user?.username}

+ {user?.email && ( +

{user.email}

+ )} +
+
+ + navigate('/settings')}> + + Settings + + + + + Sign out + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/layout/Layout.tsx b/apps/web/src/components/layout/Layout.tsx new file mode 100644 index 0000000..bdb3665 --- /dev/null +++ b/apps/web/src/components/layout/Layout.tsx @@ -0,0 +1,21 @@ +import { Outlet } from 'react-router'; +import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { AppSidebar } from './AppSidebar'; +import { Header } from './Header'; + +export function Layout() { + return ( + + + +
+ +
+ +
+
+ + + ); +} diff --git a/apps/web/src/components/layout/ServerSelector.tsx b/apps/web/src/components/layout/ServerSelector.tsx new file mode 100644 index 0000000..f932303 --- /dev/null +++ b/apps/web/src/components/layout/ServerSelector.tsx @@ -0,0 +1,59 @@ +import { useServer } from '@/hooks/useServer'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { MediaServerIcon } from '@/components/icons/MediaServerIcon'; + +export function ServerSelector() { + const { servers, selectedServerId, selectServer, isLoading, isFetching } = useServer(); + + // Show skeleton while loading initially or refetching with no cached data + if (isLoading || (servers.length === 0 && isFetching)) { + return ( +
+ +
+ ); + } + + // No servers available + if (servers.length === 0) { + return null; + } + + // Only show selector if there are multiple servers + if (servers.length === 1) { + const server = servers[0]!; + return ( +
+ + {server.name} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/layout/nav-data.ts b/apps/web/src/components/layout/nav-data.ts new file mode 100644 index 0000000..3e0719c --- /dev/null +++ b/apps/web/src/components/layout/nav-data.ts @@ -0,0 +1,48 @@ +import { + LayoutDashboard, + Map, + BarChart3, + Users, + Shield, + AlertTriangle, + Settings, + TrendingUp, + Film, + UserCircle, +} from 'lucide-react'; + +export interface NavItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +export interface NavGroup { + name: string; + icon: React.ComponentType<{ className?: string }>; + children: NavItem[]; +} + +export type NavEntry = NavItem | NavGroup; + +export function isNavGroup(entry: NavEntry): entry is NavGroup { + return 'children' in entry; +} + +export const navigation: NavEntry[] = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Map', href: '/map', icon: Map }, + { + name: 'Stats', + icon: BarChart3, + children: [ + { name: 'Activity', href: '/stats/activity', icon: TrendingUp }, + { name: 'Library', href: '/stats/library', icon: Film }, + { name: 'Users', href: '/stats/users', icon: UserCircle }, + ], + }, + { name: 'Users', href: '/users', icon: Users }, + { name: 'Rules', href: '/rules', icon: Shield }, + { name: 'Violations', href: '/violations', icon: AlertTriangle }, + { name: 'Settings', href: '/settings', icon: Settings }, +]; diff --git a/apps/web/src/components/map/StreamCard.tsx b/apps/web/src/components/map/StreamCard.tsx new file mode 100644 index 0000000..f892a80 --- /dev/null +++ b/apps/web/src/components/map/StreamCard.tsx @@ -0,0 +1,299 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router'; +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { ActiveSession, LocationStats } from '@tracearr/shared'; +import { cn } from '@/lib/utils'; +import { ActiveSessionBadge } from '@/components/sessions/ActiveSessionBadge'; +import { useTheme } from '@/components/theme-provider'; +import { User, MapPin } from 'lucide-react'; +import { getAvatarUrl } from '@/components/users/utils'; + +// Fix for default marker icons in Leaflet with bundlers +delete (L.Icon.Default.prototype as { _getIconUrl?: () => void })._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', +}); + +// Custom marker icon for active sessions +const activeSessionIcon = L.divIcon({ + className: 'stream-marker', + html: `
+
+
+
`, + iconSize: [16, 16], + iconAnchor: [8, 8], + popupAnchor: [0, -10], +}); + +// Location marker icon +const locationIcon = L.divIcon({ + className: 'location-marker', + html: `
`, + iconSize: [12, 12], + iconAnchor: [6, 6], + popupAnchor: [0, -8], +}); + +// Format media title based on type +function formatMediaTitle(session: ActiveSession): { primary: string; secondary: string | null } { + const { mediaType, mediaTitle, grandparentTitle, seasonNumber, episodeNumber, year } = session; + + if (mediaType === 'episode' && grandparentTitle) { + const seasonEp = seasonNumber && episodeNumber + ? `S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}` + : null; + return { + primary: grandparentTitle, + secondary: seasonEp ? `${seasonEp} · ${mediaTitle}` : mediaTitle, + }; + } + + if (mediaType === 'movie') { + return { primary: mediaTitle, secondary: year ? `${year}` : null }; + } + + return { primary: mediaTitle, secondary: null }; +} + +// Custom styles for popup and z-index fixes +const popupStyles = ` + /* Ensure map container doesn't overlap sidebars/modals */ + .leaflet-container { + z-index: 0 !important; + } + .leaflet-pane { + z-index: 1 !important; + } + .leaflet-tile-pane { + z-index: 1 !important; + } + .leaflet-overlay-pane { + z-index: 2 !important; + } + .leaflet-marker-pane { + z-index: 3 !important; + } + .leaflet-tooltip-pane { + z-index: 4 !important; + } + .leaflet-popup-pane { + z-index: 5 !important; + } + .leaflet-control { + z-index: 10 !important; + } + .leaflet-popup-content-wrapper { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4); + padding: 0; + } + .leaflet-popup-content { + margin: 0 !important; + min-width: 220px; + max-width: 280px; + } + .leaflet-popup-tip { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-top: none; + border-right: none; + } + .leaflet-popup-close-button { + color: hsl(var(--muted-foreground)) !important; + font-size: 18px !important; + padding: 4px 8px !important; + } + .leaflet-popup-close-button:hover { + color: hsl(var(--foreground)) !important; + } +`; + +interface StreamCardProps { + sessions?: ActiveSession[]; + locations?: LocationStats[]; + className?: string; + height?: number | string; +} + +// Component to fit bounds when data changes +function MapBoundsUpdater({ + sessions, + locations, +}: { + sessions?: ActiveSession[]; + locations?: LocationStats[]; +}) { + const map = useMap(); + + useEffect(() => { + const points: [number, number][] = []; + + sessions?.forEach((s) => { + if (s.geoLat && s.geoLon) { + points.push([s.geoLat, s.geoLon]); + } + }); + + locations?.forEach((l) => { + if (l.lat && l.lon) { + points.push([l.lat, l.lon]); + } + }); + + if (points.length > 0) { + const bounds = L.latLngBounds(points); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 10 }); + } + }, [sessions, locations, map]); + + return null; +} + +// Map tile URLs for different themes +const TILE_URLS = { + dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', +}; + +export function StreamCard({ + sessions, + locations, + className, + height = 300, +}: StreamCardProps) { + const hasData = + (sessions?.some((s) => s.geoLat && s.geoLon)) || + (locations?.some((l) => l.lat && l.lon)); + const { theme } = useTheme(); + const resolvedTheme = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + const tileUrl = TILE_URLS[resolvedTheme]; + + return ( +
+ + + + + + + {/* Active session markers */} + {sessions?.map((session) => { + if (!session.geoLat || !session.geoLon) return null; + + const avatarUrl = getAvatarUrl(session.serverId, session.user.thumbUrl, 32); + const { primary: mediaTitle, secondary: mediaSubtitle } = formatMediaTitle(session); + + return ( + + +
+ {/* Media title */} +

{mediaTitle}

+ + {/* Subtitle + status on same line */} +
+ {mediaSubtitle && ( + {mediaSubtitle} + )} + +
+ + {/* User - clickable */} + +
+ {avatarUrl ? ( + {session.user.username} + ) : ( + + )} +
+ + {session.user.identityName ?? session.user.username} + + + + {/* Meta info */} +
+ {(session.geoCity || session.geoCountry) && ( + <> + + {session.geoCity || session.geoCountry} + + )} + {(session.product || session.platform) && ( + <> + · + {session.product || session.platform} + + )} +
+
+
+
+ ); + })} + + {/* Location stats markers */} + {locations?.map((location, idx) => { + if (!location.lat || !location.lon) return null; + + return ( + + +
+
+ +
+

{location.city || 'Unknown'}

+

{location.country}

+
+
+
+ Total streams + {location.count} +
+
+
+
+ ); + })} +
+ + {!hasData && ( +
+

No location data available

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/map/StreamMap.tsx b/apps/web/src/components/map/StreamMap.tsx new file mode 100644 index 0000000..169d185 --- /dev/null +++ b/apps/web/src/components/map/StreamMap.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo } from 'react'; +import { MapContainer, TileLayer, useMap, ZoomControl, CircleMarker, Popup } from 'react-leaflet'; +import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { LocationStats } from '@tracearr/shared'; +import { cn } from '@/lib/utils'; +import { useTheme } from '@/components/theme-provider'; + +export type MapViewMode = 'heatmap' | 'circles'; + +// Custom styles for dark theme, zoom control, and z-index fixes +const mapStyles = ` + /* Ensure map container doesn't overlap sidebars/modals */ + .leaflet-container { + z-index: 0 !important; + } + .leaflet-pane { + z-index: 1 !important; + } + .leaflet-tile-pane { + z-index: 1 !important; + } + .leaflet-overlay-pane { + z-index: 2 !important; + } + .leaflet-marker-pane { + z-index: 3 !important; + } + .leaflet-tooltip-pane { + z-index: 4 !important; + } + .leaflet-popup-pane { + z-index: 5 !important; + } + .leaflet-control { + z-index: 10 !important; + } + .leaflet-control-zoom { + border: 1px solid hsl(var(--border)) !important; + border-radius: 0.5rem !important; + overflow: hidden; + } + .leaflet-control-zoom a { + background: hsl(var(--card)) !important; + color: hsl(var(--foreground)) !important; + border-bottom: 1px solid hsl(var(--border)) !important; + } + .leaflet-control-zoom a:hover { + background: hsl(var(--muted)) !important; + } + .leaflet-control-zoom a:last-child { + border-bottom: none !important; + } +`; + +interface StreamMapProps { + locations: LocationStats[]; + className?: string; + isLoading?: boolean; + viewMode?: MapViewMode; +} + +// Heatmap configuration optimized for streaming location data +const HEATMAP_CONFIG = { + // Gradient: dark cyan base → bright cyan → white hotspots + // Designed for dark map tiles with good contrast + gradient: { + 0.0: 'rgba(14, 116, 144, 0)', // cyan-700 transparent (fade from nothing) + 0.2: 'rgba(14, 116, 144, 0.8)', // cyan-700 + 0.4: '#0891b2', // cyan-600 + 0.6: '#06b6d4', // cyan-500 + 0.8: '#22d3ee', // cyan-400 + 0.95: '#67e8f9', // cyan-300 + 1.0: '#ffffff', // white for hotspots + }, + // Radius: larger for world view, heatmap auto-adjusts with zoom + radius: 30, + // Blur: soft edges for smooth transitions + blur: 20, + // minOpacity: ensure even low-activity areas are visible + minOpacity: 0.4, + // maxZoom: heatmap intensity calculation stops scaling at this zoom + maxZoom: 12, +}; + +// Circle markers layer component +function CircleMarkersLayer({ locations }: { locations: LocationStats[] }) { + const maxCount = useMemo(() => Math.max(...locations.map((l) => l.count), 1), [locations]); + + // Calculate radius based on count (scaled logarithmically) + const getRadius = (count: number) => { + const minRadius = 6; + const maxRadius = 25; + const scale = Math.log(count + 1) / Math.log(maxCount + 1); + return minRadius + scale * (maxRadius - minRadius); + }; + + // Get opacity based on count + const getOpacity = (count: number) => { + const minOpacity = 0.4; + const maxOpacity = 0.8; + const scale = count / maxCount; + return minOpacity + scale * (maxOpacity - minOpacity); + }; + + return ( + <> + {locations + .filter((l) => l.lat && l.lon) + .map((location, index) => ( + + +
+
+ {location.city ? `${location.city}, ` : ''} + {location.country || 'Unknown'} +
+
+ {location.count.toLocaleString()} stream{location.count !== 1 ? 's' : ''} +
+
+
+
+ ))} + + ); +} + +// Component to fit bounds when data changes +function MapBoundsUpdater({ locations, isLoading }: { locations: LocationStats[]; isLoading?: boolean }) { + const map = useMap(); + + useEffect(() => { + // Don't update bounds while loading - prevents zoom reset during filter changes + if (isLoading) return; + + const points: [number, number][] = locations + .filter((l) => l.lat && l.lon) + .map((l) => [l.lat, l.lon]); + + if (points.length > 0) { + const bounds = L.latLngBounds(points); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 8 }); + } + // Note: Don't zoom out when no data - preserve current view during filter transitions + }, [locations, map, isLoading]); + + return null; +} + +// Map tile URLs for different themes +const TILE_URLS = { + dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', +}; + +export function StreamMap({ locations, className, isLoading, viewMode = 'heatmap' }: StreamMapProps) { + const hasData = locations.length > 0; + const { theme } = useTheme(); + const resolvedTheme = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + const tileUrl = TILE_URLS[resolvedTheme]; + + return ( +
+ + + + + + + + {/* Visualization layer - heatmap or circles */} + {hasData && viewMode === 'heatmap' && ( + l.lat && l.lon)} + latitudeExtractor={(l: LocationStats) => l.lat} + longitudeExtractor={(l: LocationStats) => l.lon} + // Logarithmic intensity: prevents high-count locations from dominating + intensityExtractor={(l: LocationStats) => Math.log10(l.count + 1)} + gradient={HEATMAP_CONFIG.gradient} + radius={HEATMAP_CONFIG.radius} + blur={HEATMAP_CONFIG.blur} + minOpacity={HEATMAP_CONFIG.minOpacity} + maxZoom={HEATMAP_CONFIG.maxZoom} + // Dynamic max based on log scale + max={Math.log10(Math.max(...locations.map((l) => l.count), 1) + 1)} + /> + )} + {hasData && viewMode === 'circles' && } + + + {/* Loading overlay */} + {isLoading && ( +
+
+
+ Loading map data... +
+
+ )} + + {/* No data message */} + {!isLoading && !hasData && ( +
+

No location data for current filters

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/map/index.ts b/apps/web/src/components/map/index.ts new file mode 100644 index 0000000..d95ac5c --- /dev/null +++ b/apps/web/src/components/map/index.ts @@ -0,0 +1,2 @@ +export { StreamCard } from './StreamCard'; +export { StreamMap, type MapViewMode } from './StreamMap'; diff --git a/apps/web/src/components/media/MediaCard.tsx b/apps/web/src/components/media/MediaCard.tsx new file mode 100644 index 0000000..e15ffcd --- /dev/null +++ b/apps/web/src/components/media/MediaCard.tsx @@ -0,0 +1,128 @@ +import { Film, Tv, Music } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MediaCardProps { + title: string; + type: string; + showTitle?: string | null; + year?: number | null; + playCount: number; + watchTimeHours: number; + thumbPath?: string | null; + serverId?: string | null; + rank?: number; + className?: string; + /** For TV shows (aggregated series), number of unique episodes watched */ + episodeCount?: number; +} + +function MediaIcon({ type, className }: { type: string; className?: string }) { + switch (type) { + case 'movie': + return ; + case 'episode': + return ; + case 'track': + return ; + default: + return ; + } +} + +function getImageUrl(serverId: string | null | undefined, thumbPath: string | null | undefined, width = 300, height = 450) { + if (!serverId || !thumbPath) return null; + return `/api/v1/images/proxy?server=${serverId}&url=${encodeURIComponent(thumbPath)}&width=${width}&height=${height}&fallback=poster`; +} + +export function MediaCard({ + title, + type, + showTitle, + year, + playCount, + watchTimeHours, + thumbPath, + serverId, + rank, + className, + episodeCount, +}: MediaCardProps) { + const imageUrl = getImageUrl(serverId, thumbPath, 300, 450); + const bgImageUrl = getImageUrl(serverId, thumbPath, 800, 400); + // For individual episodes: showTitle is series name, title is episode name + // For aggregated shows: title is series name (no showTitle), episodeCount indicates it's aggregated + const displayTitle = type === 'episode' && showTitle ? showTitle : title; + const subtitle = episodeCount + ? `${episodeCount} episodes • ${year || ''}` + : type === 'episode' + ? title + : year + ? `(${year})` + : ''; + + return ( +
+ {/* Background with blur */} +
+
+ + {/* Content */} +
+ {/* Poster */} +
+ {imageUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + {rank && ( +
+ #{rank} +
+ )} +
+ + {/* Info */} +
+
+ + + {type} + +
+

{displayTitle}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ {playCount.toLocaleString()} + plays +
+
+ {watchTimeHours.toLocaleString()}h watched +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/media/MediaCardSmall.tsx b/apps/web/src/components/media/MediaCardSmall.tsx new file mode 100644 index 0000000..5ac4818 --- /dev/null +++ b/apps/web/src/components/media/MediaCardSmall.tsx @@ -0,0 +1,109 @@ +import { Film, Tv, Music } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MediaCardSmallProps { + title: string; + type: string; + showTitle?: string | null; + year?: number | null; + playCount: number; + thumbPath?: string | null; + serverId?: string | null; + rank?: number; + className?: string; + style?: React.CSSProperties; + /** For TV shows (aggregated series), number of unique episodes watched */ + episodeCount?: number; +} + +function MediaIcon({ type, className }: { type: string; className?: string }) { + switch (type) { + case 'movie': + return ; + case 'episode': + return ; + case 'track': + return ; + default: + return ; + } +} + +function getImageUrl(serverId: string | null | undefined, thumbPath: string | null | undefined, width = 150, height = 225) { + if (!serverId || !thumbPath) return null; + return `/api/v1/images/proxy?server=${serverId}&url=${encodeURIComponent(thumbPath)}&width=${width}&height=${height}&fallback=poster`; +} + +export function MediaCardSmall({ + title, + type, + showTitle, + year, + playCount, + thumbPath, + serverId, + rank, + className, + style, + episodeCount, +}: MediaCardSmallProps) { + const imageUrl = getImageUrl(serverId, thumbPath); + // For individual episodes: showTitle is series name, title is episode name + // For aggregated shows: title is series name (no showTitle), episodeCount indicates it's aggregated + const displayTitle = type === 'episode' && showTitle ? showTitle : title; + + return ( +
+ {/* Poster */} +
+ {imageUrl ? ( + {displayTitle} + ) : ( +
+ +
+ )} + + {/* Rank badge */} + {rank && ( +
+ {rank} +
+ )} + + {/* Hover overlay with play count */} +
+
+
{playCount}
+
plays
+
+
+
+ + {/* Info */} +
+

+ {displayTitle} +

+

+ {episodeCount + ? `${episodeCount} eps` + : type === 'episode' + ? title + : year || type} +

+
+
+ ); +} diff --git a/apps/web/src/components/media/index.ts b/apps/web/src/components/media/index.ts new file mode 100644 index 0000000..eda81a3 --- /dev/null +++ b/apps/web/src/components/media/index.ts @@ -0,0 +1,2 @@ +export { MediaCard } from './MediaCard'; +export { MediaCardSmall } from './MediaCardSmall'; diff --git a/apps/web/src/components/sessions/ActiveSessionBadge.tsx b/apps/web/src/components/sessions/ActiveSessionBadge.tsx new file mode 100644 index 0000000..ad99d88 --- /dev/null +++ b/apps/web/src/components/sessions/ActiveSessionBadge.tsx @@ -0,0 +1,30 @@ +import { Badge } from '@/components/ui/badge'; +import type { SessionState } from '@tracearr/shared'; +import { cn } from '@/lib/utils'; +import { Play, Pause, Square } from 'lucide-react'; + +interface ActiveSessionBadgeProps { + state: SessionState; + className?: string; +} + +const stateConfig: Record< + SessionState, + { variant: 'success' | 'warning' | 'secondary'; label: string; icon: typeof Play } +> = { + playing: { variant: 'success', label: 'Playing', icon: Play }, + paused: { variant: 'warning', label: 'Paused', icon: Pause }, + stopped: { variant: 'secondary', label: 'Stopped', icon: Square }, +}; + +export function ActiveSessionBadge({ state, className }: ActiveSessionBadgeProps) { + const config = stateConfig[state]; + const Icon = config.icon; + + return ( + + + {config.label} + + ); +} diff --git a/apps/web/src/components/sessions/NowPlayingCard.tsx b/apps/web/src/components/sessions/NowPlayingCard.tsx new file mode 100644 index 0000000..458f689 --- /dev/null +++ b/apps/web/src/components/sessions/NowPlayingCard.tsx @@ -0,0 +1,253 @@ +import { useState } from 'react'; +import { Monitor, Smartphone, Tablet, Tv, Play, Pause, Zap, Server, X } from 'lucide-react'; +import { getAvatarUrl } from '@/components/users/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { useEstimatedProgress } from '@/hooks/useEstimatedProgress'; +import { useAuth } from '@/hooks/useAuth'; +import { TerminateSessionDialog } from './TerminateSessionDialog'; +import type { ActiveSession } from '@tracearr/shared'; + +interface NowPlayingCardProps { + session: ActiveSession; +} + +// Get device icon based on platform/device info +function DeviceIcon({ session, className }: { session: ActiveSession; className?: string }) { + const platform = session.platform?.toLowerCase() ?? ''; + const device = session.device?.toLowerCase() ?? ''; + const product = session.product?.toLowerCase() ?? ''; + + if (platform.includes('ios') || device.includes('iphone') || platform.includes('android')) { + return ; + } + if (device.includes('ipad') || platform.includes('tablet')) { + return ; + } + if ( + platform.includes('tv') || + device.includes('tv') || + product.includes('tv') || + device.includes('roku') || + device.includes('firestick') || + device.includes('chromecast') || + device.includes('apple tv') || + device.includes('shield') + ) { + return ; + } + return ; +} + +// Format duration for display +function formatDuration(ms: number | null): string { + if (!ms) return '--:--'; + const seconds = Math.floor(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +// Get display title for media +function getMediaDisplay(session: ActiveSession): { title: string; subtitle: string | null } { + if (session.mediaType === 'episode' && session.grandparentTitle) { + // TV Show episode + const episodeInfo = + session.seasonNumber && session.episodeNumber + ? `S${session.seasonNumber.toString().padStart(2, '0')}E${session.episodeNumber.toString().padStart(2, '0')}` + : ''; + return { + title: session.grandparentTitle, + subtitle: episodeInfo ? `${episodeInfo} · ${session.mediaTitle}` : session.mediaTitle, + }; + } + // Movie or music + return { + title: session.mediaTitle, + subtitle: session.year ? `${session.year}` : null, + }; +} + +export function NowPlayingCard({ session }: NowPlayingCardProps) { + const { title, subtitle } = getMediaDisplay(session); + const { user } = useAuth(); + const [showTerminateDialog, setShowTerminateDialog] = useState(false); + + // Only admin/owner can terminate sessions + const canTerminate = user?.role === 'admin' || user?.role === 'owner'; + + // Use estimated progress for smooth updates between SSE/poll events + const { estimatedProgressMs, progressPercent } = useEstimatedProgress(session); + + // Time remaining based on estimated progress + const remaining = + session.totalDurationMs && estimatedProgressMs + ? session.totalDurationMs - estimatedProgressMs + : null; + + // Build poster URL using image proxy + const posterUrl = session.thumbPath + ? `/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=200&height=300` + : null; + + // User avatar URL (proxied for Jellyfin/Emby) + const avatarUrl = getAvatarUrl(session.serverId, session.user.thumbUrl, 28) ?? undefined; + + const isPaused = session.state === 'paused'; + + return ( +
+ {/* Background with poster blur */} + {posterUrl && ( +
+ )} + + {/* Content */} +
+ {/* Poster */} +
+ {posterUrl ? ( + {title} + ) : ( +
+ +
+ )} + + {/* Play/Pause indicator overlay */} +
+ {isPaused ? ( + + ) : ( + + )} +
+
+ + {/* Info */} +
+ {/* Top row: User and badges */} +
+
+ + + + {session.user.username.slice(0, 2).toUpperCase()} + + + + {session.user.identityName ?? session.user.username} + +
+ +
+ {/* Quality badge */} + + {session.isTranscode ? ( + <> + + Transcode + + ) : ( + 'Direct' + )} + + + {/* Device icon */} +
+ +
+ + {/* Terminate button - admin/owner only */} + {canTerminate && ( + + )} +
+
+ + {/* Middle: Title */} +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ + {/* Bottom: Progress */} +
+ +
+ {formatDuration(estimatedProgressMs)} + + {isPaused ? ( + Paused + ) : remaining ? ( + `-${formatDuration(remaining)}` + ) : ( + formatDuration(session.totalDurationMs) + )} + +
+
+
+
+ + {/* Location/Quality footer */} +
+ + {session.geoCity && session.geoCountry + ? `${session.geoCity}, ${session.geoCountry}` + : session.geoCountry ?? 'Unknown location'} + + {session.quality ?? 'Unknown quality'} +
+ + {/* Terminate confirmation dialog */} + +
+ ); +} diff --git a/apps/web/src/components/sessions/TerminateSessionDialog.tsx b/apps/web/src/components/sessions/TerminateSessionDialog.tsx new file mode 100644 index 0000000..aeea0a7 --- /dev/null +++ b/apps/web/src/components/sessions/TerminateSessionDialog.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTerminateSession } from '@/hooks/queries'; + +interface TerminateSessionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string; + mediaTitle: string; + username: string; +} + +/** + * Confirmation dialog for terminating a streaming session + * Includes optional reason field that is shown to the user + */ +export function TerminateSessionDialog({ + open, + onOpenChange, + sessionId, + mediaTitle, + username, +}: TerminateSessionDialogProps) { + const [reason, setReason] = useState(''); + const terminateSession = useTerminateSession(); + + const handleTerminate = () => { + terminateSession.mutate( + { sessionId, reason: reason.trim() || undefined }, + { + onSuccess: () => { + setReason(''); + onOpenChange(false); + }, + } + ); + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setReason(''); + } + onOpenChange(newOpen); + }; + + return ( + + + + Terminate Stream + + Stop “{mediaTitle}” for @{username}? + + +
+ + setReason(e.target.value)} + placeholder="e.g., Please don't share your account" + maxLength={500} + /> +

+ This message will be shown to them during playback +

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/sessions/index.ts b/apps/web/src/components/sessions/index.ts new file mode 100644 index 0000000..54ac21d --- /dev/null +++ b/apps/web/src/components/sessions/index.ts @@ -0,0 +1 @@ +export { NowPlayingCard } from './NowPlayingCard'; diff --git a/apps/web/src/components/settings/NotificationRoutingMatrix.tsx b/apps/web/src/components/settings/NotificationRoutingMatrix.tsx new file mode 100644 index 0000000..32a7e7a --- /dev/null +++ b/apps/web/src/components/settings/NotificationRoutingMatrix.tsx @@ -0,0 +1,196 @@ +import { Info } from 'lucide-react'; +import type { NotificationChannelRouting, NotificationEventType } from '@tracearr/shared'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useChannelRouting, useUpdateChannelRouting } from '@/hooks/queries'; + +// Display names and descriptions for event types +const EVENT_CONFIG: Record< + NotificationEventType, + { name: string; description: string } +> = { + violation_detected: { + name: 'Rule Violation', + description: 'A user triggered a rule violation (e.g., concurrent streams, impossible travel)', + }, + new_device: { + name: 'New Device', + description: 'A user logged in from a new device for the first time', + }, + trust_score_changed: { + name: 'Trust Score Changed', + description: "A user's trust score changed significantly", + }, + stream_started: { + name: 'Stream Started', + description: 'A user started watching content', + }, + stream_stopped: { + name: 'Stream Stopped', + description: 'A user stopped watching content', + }, + concurrent_streams: { + name: 'Concurrent Streams', + description: 'Multiple streams detected from the same user', + }, + server_down: { + name: 'Server Offline', + description: 'A media server became unreachable', + }, + server_up: { + name: 'Server Online', + description: 'A media server came back online', + }, +}; + +// Order of events in the table (security first, then streams, then server) +const EVENT_ORDER: NotificationEventType[] = [ + 'violation_detected', + 'new_device', + 'trust_score_changed', + 'stream_started', + 'stream_stopped', + 'concurrent_streams', + 'server_down', + 'server_up', +]; + +interface NotificationRoutingMatrixProps { + discordConfigured: boolean; + webhookConfigured: boolean; +} + +export function NotificationRoutingMatrix({ + discordConfigured, + webhookConfigured, +}: NotificationRoutingMatrixProps) { + const { data: routingData, isLoading } = useChannelRouting(); + const updateRouting = useUpdateChannelRouting(); + + // Build a map for quick lookup + const routingMap = new Map(); + routingData?.forEach((r) => routingMap.set(r.eventType, r)); + + const handleToggle = ( + eventType: NotificationEventType, + channel: 'discord' | 'webhook' | 'webToast', + checked: boolean + ) => { + updateRouting.mutate({ + eventType, + ...(channel === 'discord' && { discordEnabled: checked }), + ...(channel === 'webhook' && { webhookEnabled: checked }), + ...(channel === 'webToast' && { webToastEnabled: checked }), + }); + }; + + if (isLoading) { + return ( +
+ {EVENT_ORDER.map((eventType) => ( + + ))} +
+ ); + } + + return ( + +
+ {/* Table */} +
+ + + + Event + Web + {discordConfigured && ( + Discord + )} + {webhookConfigured && ( + Webhook + )} + + + + {EVENT_ORDER.map((eventType) => { + const routing = routingMap.get(eventType); + const config = EVENT_CONFIG[eventType]; + + return ( + + + + + + {config.name} + + + +

{config.description}

+
+
+
+ + + handleToggle(eventType, 'webToast', checked === true) + } + disabled={updateRouting.isPending} + /> + + {discordConfigured && ( + + + handleToggle(eventType, 'discord', checked === true) + } + disabled={updateRouting.isPending} + /> + + )} + {webhookConfigured && ( + + + handleToggle(eventType, 'webhook', checked === true) + } + disabled={updateRouting.isPending} + /> + + )} +
+ ); + })} +
+
+
+ + {/* Info about notification channels */} +
+ + + Web shows toast notifications in this browser. Push notifications are configured per-device in the mobile app. + +
+
+
+ ); +} diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx new file mode 100644 index 0000000..5e03e30 --- /dev/null +++ b/apps/web/src/components/theme-provider.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'dark' | 'light' | 'system'; + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +interface ThemeProviderState { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = 'dark', + storageKey = 'tracearr-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ); + + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); + return context; +}; diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx new file mode 100644 index 0000000..6469bcd --- /dev/null +++ b/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..776f8c9 --- /dev/null +++ b/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..98ac176 --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground', + secondary: 'border-transparent bg-secondary text-secondary-foreground', + destructive: 'border-transparent bg-destructive text-destructive-foreground', + outline: 'text-foreground', + success: 'border-transparent bg-green-500/15 text-green-600 dark:text-green-400', + warning: 'border-transparent bg-yellow-500/15 text-yellow-600 dark:text-yellow-400', + danger: 'border-transparent bg-red-500/15 text-red-600 dark:text-red-400', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..34c6397 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 dark:hover:text-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 dark:hover:text-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..8c799c4 --- /dev/null +++ b/apps/web/src/components/ui/calendar.tsx @@ -0,0 +1,216 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
} + className={cn(className)} + {...props} + /> + ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..4712324 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..4703ead --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,179 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/web/src/components/ui/empty-state.tsx b/apps/web/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..cb3e505 --- /dev/null +++ b/apps/web/src/components/ui/empty-state.tsx @@ -0,0 +1,40 @@ +import type { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface EmptyStateProps { + icon?: LucideIcon; + title: string; + description?: string; + children?: React.ReactNode; + className?: string; +} + +export function EmptyState({ + icon: Icon, + title, + description, + children, + className, +}: EmptyStateProps) { + return ( +
+ {Icon && ( +
+ +
+ )} +

{title}

+ {description && ( +

+ {description} +

+ )} + {children &&
{children}
} +
+ ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..b651d6b --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..804f159 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +); + +const Label = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/loading-spinner.tsx b/apps/web/src/components/ui/loading-spinner.tsx new file mode 100644 index 0000000..8dba253 --- /dev/null +++ b/apps/web/src/components/ui/loading-spinner.tsx @@ -0,0 +1,42 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface LoadingSpinnerProps { + className?: string; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-6 w-6', + lg: 'h-8 w-8', +}; + +export function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps) { + return ( + + ); +} + +interface LoadingOverlayProps { + message?: string; +} + +export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) { + return ( +
+ +

{message}

+
+ ); +} + +export function PageLoadingSpinner() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ui/mode-toggle.tsx b/apps/web/src/components/ui/mode-toggle.tsx new file mode 100644 index 0000000..8e78876 --- /dev/null +++ b/apps/web/src/components/ui/mode-toggle.tsx @@ -0,0 +1,30 @@ +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useTheme } from '@/components/theme-provider'; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme('light')}>Light + setTheme('dark')}>Dark + setTheme('system')}>System + + + ); +} diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx new file mode 100644 index 0000000..1dcfb0c --- /dev/null +++ b/apps/web/src/components/ui/pagination.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants, type Button } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +