diff --git a/.github/workflows/backend-foundation.yml b/.github/workflows/backend-foundation.yml new file mode 100644 index 00000000..0e408f8f --- /dev/null +++ b/.github/workflows/backend-foundation.yml @@ -0,0 +1,64 @@ +name: Backend Foundation + +on: + pull_request: + branches: + - dev + - main + push: + branches: + - dev + - main + +jobs: + backend-foundation-makefile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate backend make targets + run: | + make backend-help + make help | grep "backend-" + + - name: Dry-run backend automation targets + run: | + make -n backend-enable-apis ENV=dev + make -n backend-bootstrap-dev ENV=dev + make -n backend-deploy-core ENV=dev + make -n backend-deploy-commands ENV=dev + make -n backend-deploy-workers ENV=dev + make -n backend-smoke-core ENV=dev + make -n backend-smoke-commands ENV=dev + make -n backend-logs-core ENV=dev + + backend-services-tests: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - backend/core-api + - backend/command-api + defaults: + run: + working-directory: ${{ matrix.service }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ${{ matrix.service }}/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + env: + AUTH_BYPASS: "true" + LLM_MOCK: "true" + run: npm test diff --git a/.gitignore b/.gitignore index c3c5a87f..e91fb146 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ lerna-debug.log* *.temp tmp/ temp/ +scripts/issues-to-create.md # ============================================================================== # SECURITY (CRITICAL) @@ -186,3 +187,9 @@ krow-workforce-export-latest/ # Data Connect Generated SDKs (Explicit) apps/mobile/packages/data_connect/lib/src/dataconnect_generated/ apps/web/src/dataconnect-generated/ + + +AGENTS.md +CLAUDE.md +GEMINI.md +TASKS.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b3b40911 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# KROW Workforce Change Log + +| Date | Version | Change | +|---|---|---| +| 2026-02-24 | 0.1.0 | Confirmed dev owner access and current runtime baseline in `krow-workforce-dev`. | +| 2026-02-24 | 0.1.1 | Added backend foundation implementation plan document. | +| 2026-02-24 | 0.1.2 | Added API implementation contract and transition route aliases. | +| 2026-02-24 | 0.1.3 | Added auth-first security policy with deferred role-map integration hooks. | +| 2026-02-24 | 0.1.4 | Locked defaults for idempotency, validation, bucket split, model provider, and p95 objectives. | +| 2026-02-24 | 0.1.5 | Added backend makefile module and CI workflow for backend target validation. | +| 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | +| 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | +| 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | +| 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | +| 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. | +| 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | +| 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | +| 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | +| 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. | +| 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. | +| 2026-02-24 | 0.1.16 | Added M4 target schema blueprint doc with first-principles modular model, constraints, and migration phases. | +| 2026-02-24 | 0.1.17 | Added full current-schema mermaid model relationship map to the M4 target schema blueprint. | +| 2026-02-24 | 0.1.18 | Updated schema blueprint with explicit multi-tenant stakeholder model and phased RBAC rollout with shadow mode before enforcement. | +| 2026-02-24 | 0.1.19 | Added customer stakeholder-wheel mapping and future stakeholder extension model to the M4 schema blueprint. | +| 2026-02-25 | 0.1.20 | Added roadmap CSV schema-reconciliation document with stakeholder capability matrix and concrete schema gap analysis. | +| 2026-02-25 | 0.1.21 | Updated target schema blueprint with roadmap-evidence section plus attendance/offense, stakeholder-network, and settlement-table coverage. | +| 2026-02-25 | 0.1.22 | Updated core actor scenarios with explicit business and vendor user partitioning via membership tables. | +| 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. | +| 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. | +| 2026-02-25 | 0.1.25 | Added target schema model catalog with keys and domain relationship diagrams for slide/workshop use. | diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9403874e..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,129 +0,0 @@ -# CLAUDE.md - Project Context for AI Assistants - -This file provides context for Claude Code and other AI assistants working on this codebase. - -## Project Overview - -**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of: -- **Client App**: For businesses to create orders, manage hubs, handle billing -- **Staff App**: For workers to manage availability, clock in/out, view earnings -- **Web Dashboard**: Admin portal (React/Vite - WIP) -- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL - -## Monorepo Structure - -``` -/apps - /mobile # Flutter apps (managed by Melos) - /apps - /client # krowwithus_client - Business app - /staff # krowwithus_staff - Worker app - /design_system_viewer - /packages - /core # Base utilities - /domain # Business entities, repository interfaces - /data_connect # Data layer, Firebase Data Connect SDK - /design_system # Shared UI components - /core_localization # i18n (Slang) - /features - /client/* # Client-specific features - /staff/* # Staff-specific features - /web-dashboard # React web app (WIP) -/backend - /dataconnect # GraphQL schemas, Firebase Data Connect config - /cloud-functions # Serverless functions (placeholder) -/internal - /launchpad # Internal DevOps portal - /api-harness # API testing tool -/makefiles # Modular Make targets -/docs # Project documentation -``` - -## Key Commands - -### Mobile Development -```bash -# Install dependencies -make mobile-install - -# Run client app (specify your device ID) -make mobile-client-dev-android DEVICE= - -# Run staff app -make mobile-staff-dev-android DEVICE= - -# Find your device ID -flutter devices - -# Build APK -make mobile-client-build PLATFORM=apk -make mobile-staff-build PLATFORM=apk - -# Code generation (localization + build_runner) -cd apps/mobile && melos run gen:all -``` - -### Web Development -```bash -make install # Install web dependencies -make dev # Run web dev server -``` - -### Data Connect -```bash -make dataconnect-sync # Deploy schemas, migrate, regenerate SDK -``` - -## Architecture Patterns - -- **State Management**: BLoC pattern (flutter_bloc) -- **Navigation**: Flutter Modular -- **Architecture**: Clean Architecture (domain/data/presentation layers) -- **Feature Organization**: Each feature is a separate package -- **Value Objects**: Equatable for entity equality - -## Code Conventions - -- Features go in `/apps/mobile/packages/features/{client|staff}/` -- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/` -- UI components go in `/apps/mobile/packages/design_system/` -- GraphQL schemas go in `/backend/dataconnect/schema/` -- Documentation language: **English** - -## Important Files - -- `apps/mobile/melos.yaml` - Melos workspace config -- `makefiles/mobile.mk` - Mobile Make targets -- `backend/dataconnect/dataconnect.yaml` - Data Connect config -- `firebase.json` - Firebase hosting/emulator config -- `BLOCKERS.md` - Known blockers and deviations - -## Branch Protection - -- `main` and `dev` branches are protected -- Always create feature branches: `feature/`, `fix/`, `chore/` -- PRs required for merging - -## Testing Mobile Apps - -1. Connect your Android device or start emulator -2. Run `flutter devices` to get device ID -3. Run `make mobile-client-dev-android DEVICE=` - -## Common Issues - -### "No devices found with name 'android'" -The Makefile defaults to device ID `android`. Override with your actual device: -```bash -make mobile-client-dev-android DEVICE=3fb285a7 -``` - -### Dependency resolution issues -```bash -cd apps/mobile && melos clean && melos bootstrap -``` - -### Code generation out of sync -```bash -cd apps/mobile && melos run gen:all -``` diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 1a9c4fe0..00000000 --- a/GEMINI.md +++ /dev/null @@ -1,138 +0,0 @@ -# GEMINI.md - Project Context for AI Assistants - -This file provides context for Gemini and other AI assistants working on this codebase. - -## Project Overview - -**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of: -- **Client App**: For businesses to create orders, manage hubs, handle billing -- **Staff App**: For workers to manage availability, clock in/out, view earnings -- **Web Dashboard**: Admin portal (React/Vite - WIP) -- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL - -## Monorepo Structure - -``` -/apps - /mobile # Flutter apps (managed by Melos) - /apps - /client # krowwithus_client - Business app - /staff # krowwithus_staff - Worker app - /design_system_viewer - /packages - /core # Base utilities - /domain # Business entities, repository interfaces - /data_connect # Data layer, Firebase Data Connect SDK - /design_system # Shared UI components - /core_localization # i18n (Slang) - /features - /client/* # Client-specific features - /staff/* # Staff-specific features - /web-dashboard # React web app (WIP) -/backend - /dataconnect # GraphQL schemas, Firebase Data Connect config - /cloud-functions # Serverless functions (placeholder) -/internal - /launchpad # Internal DevOps portal - /api-harness # API testing tool -/makefiles # Modular Make targets -/docs # Project documentation -/bugs # Bug reports and screenshots -``` - -## Key Commands - -### Mobile Development -```bash -# Install dependencies -make mobile-install - -# Run client app (specify your device ID) -make mobile-client-dev-android DEVICE= - -# Run staff app -make mobile-staff-dev-android DEVICE= - -# Find your device ID -flutter devices - -# Build APK -make mobile-client-build PLATFORM=apk -make mobile-staff-build PLATFORM=apk - -# Code generation (localization + build_runner) -cd apps/mobile && melos run gen:all -``` - -### Web Development -```bash -make install # Install web dependencies -make dev # Run web dev server -``` - -### Data Connect -```bash -make dataconnect-sync # Deploy schemas, migrate, regenerate SDK -``` - -## Architecture Patterns - -- **State Management**: BLoC pattern (flutter_bloc) -- **Navigation**: Flutter Modular -- **Architecture**: Clean Architecture (domain/data/presentation layers) -- **Feature Organization**: Each feature is a separate package -- **Value Objects**: Equatable for entity equality - -## Code Conventions - -- Features go in `/apps/mobile/packages/features/{client|staff}/` -- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/` -- UI components go in `/apps/mobile/packages/design_system/` -- GraphQL schemas go in `/backend/dataconnect/schema/` -- Documentation language: **English** - -## Important Files - -- `apps/mobile/melos.yaml` - Melos workspace config -- `makefiles/mobile.mk` - Mobile Make targets -- `backend/dataconnect/dataconnect.yaml` - Data Connect config -- `firebase.json` - Firebase hosting/emulator config -- `BLOCKERS.md` - Known blockers and deviations -- `bugs/BUG-REPORT-*.md` - Bug reports with analysis - -## Branch Protection - -- `main` and `dev` branches are protected -- Always create feature branches: `feature/`, `fix/`, `chore/` -- PRs required for merging - -## Testing Mobile Apps - -1. Connect your Android device or start emulator -2. Run `flutter devices` to get device ID -3. Run `make mobile-client-dev-android DEVICE=` - -## Common Issues - -### "No devices found with name 'android'" -The Makefile defaults to device ID `android`. Override with your actual device: -```bash -make mobile-client-dev-android DEVICE=3fb285a7 -``` - -### Dependency resolution issues -```bash -cd apps/mobile && melos clean && melos bootstrap -``` - -### Code generation out of sync -```bash -cd apps/mobile && melos run gen:all -``` - -## Known Technical Debt - -See `bugs/BUG-REPORT-*.md` for detailed analysis of: -- Authentication/User sync issues -- Error handling architecture (needs AppException pattern) -- BLoC state management patterns (copyWith null handling) diff --git a/Makefile b/Makefile index 2b2f8c55..71d0b18d 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ include makefiles/web.mk include makefiles/launchpad.mk include makefiles/mobile.mk include makefiles/dataconnect.mk +include makefiles/backend.mk include makefiles/tools.mk # --- Main Help Command --- @@ -55,6 +56,9 @@ help: @echo " make mobile-test Run flutter test for client+staff" @echo " make mobile-hot-reload Hot reload running Flutter app" @echo " make mobile-hot-restart Hot restart running Flutter app" + @echo " make test-e2e Run full Maestro E2E suite (Client + Staff auth)" + @echo " make test-e2e-client Run Client Maestro E2E only" + @echo " make test-e2e-staff Run Staff Maestro E2E only" @echo "" @echo " 🗄️ DATA CONNECT & BACKEND (backend/dataconnect)" @echo " ────────────────────────────────────────────────────────────────────" @@ -71,6 +75,19 @@ help: @echo " make dataconnect-bootstrap-validation-database ONE-TIME: Setup validation database" @echo " make dataconnect-backup-dev-to-validation Backup dev database to validation" @echo "" + @echo " ☁️ BACKEND FOUNDATION (Cloud Run + Workers)" + @echo " ────────────────────────────────────────────────────────────────────" + @echo " make backend-help Show backend foundation commands" + @echo " make backend-enable-apis [ENV=dev] Enable backend GCP APIs" + @echo " make backend-bootstrap-dev Bootstrap backend foundation resources (dev)" + @echo " make backend-migrate-idempotency Create/upgrade command idempotency table" + @echo " make backend-deploy-core [ENV=dev] Build and deploy core API service" + @echo " make backend-deploy-commands [ENV=dev] Build and deploy command API service" + @echo " make backend-deploy-workers [ENV=dev] Deploy async worker functions scaffold" + @echo " make backend-smoke-core [ENV=dev] Run health smoke test for core service (/health)" + @echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service (/health)" + @echo " make backend-logs-core [ENV=dev] Tail/read logs for core service" + @echo "" @echo " 🛠️ DEVELOPMENT TOOLS" @echo " ────────────────────────────────────────────────────────────────────" @echo " make install-melos Install Melos globally (for mobile dev)" diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index de98cbea..f3808646 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -30,6 +35,16 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 69b16696..8b0a7da5 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -24,6 +30,12 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -39,9 +51,11 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; } diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index c4ba9dcf..30780dc6 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -12,6 +14,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/client/maestro/README.md b/apps/mobile/apps/client/maestro/README.md new file mode 100644 index 00000000..ef8712a1 --- /dev/null +++ b/apps/mobile/apps/client/maestro/README.md @@ -0,0 +1,33 @@ +# Maestro Integration Tests — Client App + +Auth flows for the KROW Client app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md). + +## Structure + +``` +maestro/ + auth/ + sign_in.yaml + sign_up.yaml +``` + +## Credentials (env, never hardcoded) + +| Flow | Env variables | +|------|---------------| +| sign_in | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD` | +| sign_up | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD`, `TEST_CLIENT_COMPANY` | + +## Run + +```bash +# Via Makefile (export vars first) +make test-e2e-client + +# Direct +maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml \ + -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... +maestro test apps/mobile/apps/client/maestro/auth/sign_up.yaml \ + -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... -e TEST_CLIENT_COMPANY=... +``` diff --git a/apps/mobile/apps/client/maestro/auth/sign_in.yaml b/apps/mobile/apps/client/maestro/auth/sign_in.yaml new file mode 100644 index 00000000..245a09f3 --- /dev/null +++ b/apps/mobile/apps/client/maestro/auth/sign_in.yaml @@ -0,0 +1,22 @@ +# Client App — Sign In flow +# Credentials via env: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD +# Run: maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... +# Or: export MAESTRO_TEST_CLIENT_EMAIL / MAESTRO_TEST_CLIENT_PASSWORD (Maestro auto-reads MAESTRO_*) + +appId: com.krowwithus.client +env: + EMAIL: ${TEST_CLIENT_EMAIL} + PASSWORD: ${TEST_CLIENT_PASSWORD} +--- +- launchApp +- assertVisible: "Sign In" +- tapOn: "Sign In" +- assertVisible: "Email" +- tapOn: + id: sign_in_email +- inputText: ${EMAIL} +- tapOn: + id: sign_in_password +- inputText: ${PASSWORD} +- tapOn: "Sign In" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/maestro/auth/sign_up.yaml b/apps/mobile/apps/client/maestro/auth/sign_up.yaml new file mode 100644 index 00000000..010bbe8a --- /dev/null +++ b/apps/mobile/apps/client/maestro/auth/sign_up.yaml @@ -0,0 +1,28 @@ +# Client App — Sign Up flow +# Credentials via env: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD, TEST_CLIENT_COMPANY +# Run: maestro test apps/mobile/apps/client/maestro/auth/sign_up.yaml -e TEST_CLIENT_EMAIL=... -e TEST_CLIENT_PASSWORD=... -e TEST_CLIENT_COMPANY=... + +appId: com.krowwithus.client +env: + EMAIL: ${TEST_CLIENT_EMAIL} + PASSWORD: ${TEST_CLIENT_PASSWORD} + COMPANY: ${TEST_CLIENT_COMPANY} +--- +- launchApp +- assertVisible: "Create Account" +- tapOn: "Create Account" +- assertVisible: "Company" +- tapOn: + id: sign_up_company +- inputText: ${COMPANY} +- tapOn: + id: sign_up_email +- inputText: ${EMAIL} +- tapOn: + id: sign_up_password +- inputText: ${PASSWORD} +- tapOn: + id: sign_up_confirm_password +- inputText: ${PASSWORD} +- tapOn: "Create Account" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 869eecae..3a3369d4 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index 7ba8383b..b9b24c8b 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core url_launcher_windows diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index ee04ee9a..fbdc8215 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -45,6 +50,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index 7a704337..e8a688bb 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -36,6 +42,12 @@ @import google_maps_flutter_ios; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -57,11 +69,13 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index d127d3e1..5557a971 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; -import 'package:krow_core/core.dart'; import 'src/widgets/session_listener.dart'; @@ -26,7 +26,10 @@ void main() async { // Initialize session listener for Firebase Auth state changes DataConnectService.instance.initializeAuthListener( - allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + allowedRoles: [ + 'STAFF', + 'BOTH', + ], // Only allow users with STAFF or BOTH roles ); runApp( @@ -40,11 +43,11 @@ void main() async { /// The main application module. class AppModule extends Module { @override - List get imports => - [ - core_localization.LocalizationModule(), - staff_authentication.StaffAuthenticationModule(), - ]; + List get imports => [ + CoreModule(), + core_localization.LocalizationModule(), + staff_authentication.StaffAuthenticationModule(), + ]; @override void routes(RouteManager r) { diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index 83c9214f..56b4b1e5 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -13,6 +15,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/staff/maestro/README.md b/apps/mobile/apps/staff/maestro/README.md new file mode 100644 index 00000000..f790d243 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/README.md @@ -0,0 +1,38 @@ +# Maestro Integration Tests — Staff App + +Auth flows for the KROW Staff app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) and [maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md). + +## Structure + +``` +maestro/ + auth/ + sign_in.yaml + sign_up.yaml +``` + +## Prerequisites + +- Firebase test phone in Auth > Phone (e.g. +1 555-765-4321 / OTP 123456) +- For sign_up: use a different test number (not yet registered) + +## Credentials (env, never hardcoded) + +| Flow | Env variables | +|------|---------------| +| sign_in | `TEST_STAFF_PHONE`, `TEST_STAFF_OTP` | +| sign_up | `TEST_STAFF_SIGNUP_PHONE`, `TEST_STAFF_OTP` | + +## Run + +```bash +# Via Makefile (export vars first) +make test-e2e-staff + +# Direct +maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml \ + -e TEST_STAFF_PHONE=5557654321 -e TEST_STAFF_OTP=123456 +maestro test apps/mobile/apps/staff/maestro/auth/sign_up.yaml \ + -e TEST_STAFF_SIGNUP_PHONE=... -e TEST_STAFF_OTP=123456 +``` diff --git a/apps/mobile/apps/staff/maestro/auth/sign_in.yaml b/apps/mobile/apps/staff/maestro/auth/sign_in.yaml new file mode 100644 index 00000000..17e0dc31 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/auth/sign_in.yaml @@ -0,0 +1,24 @@ +# Staff App — Sign In flow (Phone + OTP) +# Credentials via env: TEST_STAFF_PHONE, TEST_STAFF_OTP +# Firebase: add test phone in Auth > Phone (e.g. +1 555-765-4321 / OTP 123456) +# Run: maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml -e TEST_STAFF_PHONE=5557654321 -e TEST_STAFF_OTP=123456 + +appId: com.krowwithus.staff +env: + PHONE: ${TEST_STAFF_PHONE} + OTP: ${TEST_STAFF_OTP} +--- +- launchApp +- assertVisible: "Log In" +- tapOn: "Log In" +- assertVisible: "Send Code" +- tapOn: + id: staff_phone_input +- inputText: ${PHONE} +- tapOn: "Send Code" +# OTP screen: Continue button visible until we finish typing +- assertVisible: "Continue" +- tapOn: + id: staff_otp_input +- inputText: ${OTP} +# OTP auto-submits when 6th digit is entered; app navigates to staff main diff --git a/apps/mobile/apps/staff/maestro/auth/sign_up.yaml b/apps/mobile/apps/staff/maestro/auth/sign_up.yaml new file mode 100644 index 00000000..e6e7c1bd --- /dev/null +++ b/apps/mobile/apps/staff/maestro/auth/sign_up.yaml @@ -0,0 +1,23 @@ +# Staff App — Sign Up flow (Phone + OTP) +# Credentials via env: TEST_STAFF_SIGNUP_PHONE, TEST_STAFF_OTP +# Use a NEW Firebase test phone (not yet registered) +# Run: maestro test apps/mobile/apps/staff/maestro/auth/sign_up.yaml -e TEST_STAFF_SIGNUP_PHONE=... -e TEST_STAFF_OTP=123456 + +appId: com.krowwithus.staff +env: + PHONE: ${TEST_STAFF_SIGNUP_PHONE} + OTP: ${TEST_STAFF_OTP} +--- +- launchApp +- assertVisible: "Sign Up" +- tapOn: "Sign Up" +- assertVisible: "Send Code" +- tapOn: + id: staff_phone_input +- inputText: ${PHONE} +- tapOn: "Send Code" +# OTP auto-submits when 6th digit entered +- assertVisible: "Continue" +- tapOn: + id: staff_otp_input +- inputText: ${OTP} diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index 148eb231..f06cf63c 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 333a9eb4..e3928570 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core geolocator_windows diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index 95c65c67..a6d85eec 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,3 +1,4 @@ { - "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0", + "CORE_API_BASE_URL": "https://krow-core-api-e3g6witsvq-uc.a.run.app" } \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 0aa4de1d..e5dff061 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -1,5 +1,7 @@ library; +export 'src/core_module.dart'; + export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; export 'src/utils/date_time_utils.dart'; @@ -8,3 +10,22 @@ export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/config/app_config.dart'; export 'src/routing/routing.dart'; +export 'src/services/api_service/api_service.dart'; +export 'src/services/api_service/dio_client.dart'; + +// Core API Services +export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_response.dart'; +export 'src/services/api_service/core_api_services/llm/llm_service.dart'; +export 'src/services/api_service/core_api_services/llm/llm_response.dart'; +export 'src/services/api_service/core_api_services/verification/verification_service.dart'; +export 'src/services/api_service/core_api_services/verification/verification_response.dart'; + +// Device Services +export 'src/services/device/camera/camera_service.dart'; +export 'src/services/device/gallery/gallery_service.dart'; +export 'src/services/device/file/file_picker_service.dart'; +export 'src/services/device/file_upload/device_file_upload_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 9bf56394..6752f3c6 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -5,5 +5,12 @@ class AppConfig { AppConfig._(); /// The Google Maps API key. - static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY'); + static const String googleMapsApiKey = String.fromEnvironment( + 'GOOGLE_MAPS_API_KEY', + ); + + /// The base URL for the Core API. + static const String coreApiBaseUrl = String.fromEnvironment( + 'CORE_API_BASE_URL', + ); } diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart new file mode 100644 index 00000000..bd782a8a --- /dev/null +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../core.dart'; + +/// A module that provides core services and shared dependencies. +/// +/// This module should be imported by the root [AppModule] to make +/// core services available globally as singletons. +class CoreModule extends Module { + @override + void exportedBinds(Injector i) { + // 1. Register the base HTTP client + i.addSingleton(() => DioClient()); + + // 2. Register the base API service + i.addSingleton(() => ApiService(i.get())); + + // 3. Register Core API Services (Orchestrators) + i.addSingleton( + () => FileUploadService(i.get()), + ); + i.addSingleton( + () => SignedUrlService(i.get()), + ); + i.addSingleton( + () => VerificationService(i.get()), + ); + i.addSingleton(() => LlmService(i.get())); + + // 4. Register Device dependency + i.addSingleton(() => ImagePicker()); + + // 5. Register Device Services + i.addSingleton(() => CameraService(i.get())); + i.addSingleton(() => GalleryService(i.get())); + i.addSingleton(FilePickerService.new); + i.addSingleton( + () => DeviceFileUploadService( + cameraService: i.get(), + galleryService: i.get(), + apiUploadService: i.get(), + ), + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index edb5141e..a3650f69 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -135,6 +135,11 @@ extension ClientNavigator on IModularNavigator { pushNamed(ClientPaths.settings); } + /// Pushes the edit profile page. + void toClientEditProfile() { + pushNamed('${ClientPaths.settings}/edit-profile'); + } + // ========================================================================== // HUBS MANAGEMENT // ========================================================================== @@ -159,6 +164,9 @@ extension ClientNavigator on IModularNavigator { return pushNamed( ClientPaths.editHub, arguments: {'hub': hub}, + // Some versions of Modular allow passing opaque here, but if not + // we'll handle transparency in the page itself which we already do. + // To ensure it's not opaque, we'll use push with a PageRouteBuilder if needed. ); } diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 7b8a9f25..5d62480c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -196,7 +196,22 @@ extension StaffNavigator on IModularNavigator { /// /// Record sizing and appearance information for uniform allocation. void toAttire() { - pushNamed(StaffPaths.attire); + navigate(StaffPaths.attire); + } + + /// Pushes the attire capture page. + /// + /// Parameters: + /// * [item] - The attire item to capture + /// * [initialPhotoUrl] - Optional initial photo URL + void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { + navigate( + StaffPaths.attireCapture, + arguments: { + 'item': item, + 'initialPhotoUrl': initialPhotoUrl, + }, + ); } // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index f0a602ab..4929e1a0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -152,6 +152,9 @@ class StaffPaths { /// Record sizing and appearance information for uniform allocation. static const String attire = '/worker-main/attire/'; + /// Attire capture page. + static const String attireCapture = '/worker-main/attire/capture/'; + // ========================================================================== // COMPLIANCE & DOCUMENTS // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart new file mode 100644 index 00000000..db1119c9 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -0,0 +1,127 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A service that handles HTTP communication using the [Dio] client. +/// +/// This class provides a wrapper around [Dio]'s methods to handle +/// response parsing and error handling in a consistent way. +class ApiService implements BaseApiService { + /// Creates an [ApiService] with the given [Dio] instance. + ApiService(this._dio); + + /// The underlying [Dio] client used for network requests. + final Dio _dio; + + /// Performs a GET request to the specified [endpoint]. + @override + Future get( + String endpoint, { + Map? params, + }) async { + try { + final Response response = await _dio.get( + endpoint, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a POST request to the specified [endpoint]. + @override + Future post( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.post( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PUT request to the specified [endpoint]. + @override + Future put( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.put( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PATCH request to the specified [endpoint]. + @override + Future patch( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.patch( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Extracts [ApiResponse] from a successful [Response]. + ApiResponse _handleResponse(Response response) { + return ApiResponse( + code: response.statusCode?.toString() ?? '200', + message: response.data['message']?.toString() ?? 'Success', + data: response.data, + ); + } + + /// Extracts [ApiResponse] from a [DioException]. + ApiResponse _handleError(DioException e) { + if (e.response?.data is Map) { + final Map body = + e.response!.data as Map; + return ApiResponse( + code: + body['code']?.toString() ?? + e.response?.statusCode?.toString() ?? + 'error', + message: body['message']?.toString() ?? e.message ?? 'Error occurred', + data: body['data'], + errors: _parseErrors(body['errors']), + ); + } + return ApiResponse( + code: e.response?.statusCode?.toString() ?? 'error', + message: e.message ?? 'Unknown error', + errors: {'exception': e.type.toString()}, + ); + } + + /// Helper to parse the errors map from various possible formats. + Map _parseErrors(dynamic errors) { + if (errors is Map) { + return Map.from(errors); + } + return const {}; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart new file mode 100644 index 00000000..1c2a80cd --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart @@ -0,0 +1,33 @@ +import '../../../config/app_config.dart'; + +/// Constants for Core API endpoints. +class CoreApiEndpoints { + CoreApiEndpoints._(); + + /// The base URL for the Core API. + static const String baseUrl = AppConfig.coreApiBaseUrl; + + /// Upload a file. + static const String uploadFile = '$baseUrl/core/upload-file'; + + /// Create a signed URL for a file. + static const String createSignedUrl = '$baseUrl/core/create-signed-url'; + + /// Invoke a Large Language Model. + static const String invokeLlm = '$baseUrl/core/invoke-llm'; + + /// Root for verification operations. + static const String verifications = '$baseUrl/core/verifications'; + + /// Get status of a verification job. + static String verificationStatus(String id) => + '$baseUrl/core/verifications/$id'; + + /// Review a verification decision. + static String verificationReview(String id) => + '$baseUrl/core/verifications/$id/review'; + + /// Retry a verification job. + static String verificationRetry(String id) => + '$baseUrl/core/verifications/$id/retry'; +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart new file mode 100644 index 00000000..941fe01d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart @@ -0,0 +1,54 @@ +/// Response model for file upload operation. +class FileUploadResponse { + /// Creates a [FileUploadResponse]. + const FileUploadResponse({ + required this.fileUri, + required this.contentType, + required this.size, + required this.bucket, + required this.path, + this.requestId, + }); + + /// Factory to create [FileUploadResponse] from JSON. + factory FileUploadResponse.fromJson(Map json) { + return FileUploadResponse( + fileUri: json['fileUri'] as String, + contentType: json['contentType'] as String, + size: json['size'] as int, + bucket: json['bucket'] as String, + path: json['path'] as String, + requestId: json['requestId'] as String?, + ); + } + + /// The Cloud Storage URI of the uploaded file. + final String fileUri; + + /// The MIME type of the file. + final String contentType; + + /// The size of the file in bytes. + final int size; + + /// The bucket where the file was uploaded. + final String bucket; + + /// The path within the bucket. + final String path; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'fileUri': fileUri, + 'contentType': contentType, + 'size': size, + 'bucket': bucket, + 'path': path, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart new file mode 100644 index 00000000..09dc2854 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'file_upload_response.dart'; + +/// Service for uploading files to the Core API. +class FileUploadService extends BaseCoreService { + /// Creates a [FileUploadService]. + FileUploadService(super.api); + + /// Uploads a file with optional visibility and category. + /// + /// [filePath] is the local path to the file. + /// [visibility] can be [FileVisibility.public] or [FileVisibility.private]. + /// [category] is an optional metadata field. + Future uploadFile({ + required String filePath, + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + final ApiResponse res = await action(() async { + final FormData formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + 'visibility': visibility.value, + if (category != null) 'category': category, + }); + + return api.post(CoreApiEndpoints.uploadFile, data: formData); + }); + + if (res.code.startsWith('2')) { + return FileUploadResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart new file mode 100644 index 00000000..add3c331 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart @@ -0,0 +1,42 @@ +/// Response model for LLM invocation. +class LlmResponse { + /// Creates an [LlmResponse]. + const LlmResponse({ + required this.result, + required this.model, + required this.latencyMs, + this.requestId, + }); + + /// Factory to create [LlmResponse] from JSON. + factory LlmResponse.fromJson(Map json) { + return LlmResponse( + result: json['result'] as Map, + model: json['model'] as String, + latencyMs: json['latencyMs'] as int, + requestId: json['requestId'] as String?, + ); + } + + /// The JSON result returned by the model. + final Map result; + + /// The model name used for invocation. + final String model; + + /// Time taken for the request in milliseconds. + final int latencyMs; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'result': result, + 'model': model, + 'latencyMs': latencyMs, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart new file mode 100644 index 00000000..5bf6208d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -0,0 +1,38 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'llm_response.dart'; + +/// Service for invoking Large Language Models (LLM). +class LlmService extends BaseCoreService { + /// Creates an [LlmService]. + LlmService(super.api); + + /// Invokes the LLM with a [prompt] and optional [schema]. + /// + /// [prompt] is the text instruction for the model. + /// [responseJsonSchema] is an optional JSON schema to enforce structure. + /// [fileUrls] are optional URLs of files (images/PDFs) to include in context. + Future invokeLlm({ + required String prompt, + Map? responseJsonSchema, + List? fileUrls, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.invokeLlm, + data: { + 'prompt': prompt, + if (responseJsonSchema != null) + 'responseJsonSchema': responseJsonSchema, + if (fileUrls != null) 'fileUrls': fileUrls, + }, + ); + }); + + if (res.code.startsWith('2')) { + return LlmResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart new file mode 100644 index 00000000..bf286f07 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart @@ -0,0 +1,36 @@ +/// Response model for creating a signed URL. +class SignedUrlResponse { + /// Creates a [SignedUrlResponse]. + const SignedUrlResponse({ + required this.signedUrl, + required this.expiresAt, + this.requestId, + }); + + /// Factory to create [SignedUrlResponse] from JSON. + factory SignedUrlResponse.fromJson(Map json) { + return SignedUrlResponse( + signedUrl: json['signedUrl'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + requestId: json['requestId'] as String?, + ); + } + + /// The generated signed URL. + final String signedUrl; + + /// The timestamp when the URL expires. + final DateTime expiresAt; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'signedUrl': signedUrl, + 'expiresAt': expiresAt.toIso8601String(), + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart new file mode 100644 index 00000000..f25fea52 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -0,0 +1,34 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'signed_url_response.dart'; + +/// Service for creating signed URLs for Cloud Storage objects. +class SignedUrlService extends BaseCoreService { + /// Creates a [SignedUrlService]. + SignedUrlService(super.api); + + /// Creates a signed URL for a specific [fileUri]. + /// + /// [fileUri] should be in gs:// format. + /// [expiresInSeconds] must be <= 900. + Future createSignedUrl({ + required String fileUri, + int expiresInSeconds = 300, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.createSignedUrl, + data: { + 'fileUri': fileUri, + 'expiresInSeconds': expiresInSeconds, + }, + ); + }); + + if (res.code.startsWith('2')) { + return SignedUrlResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart new file mode 100644 index 00000000..38f2ba25 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart @@ -0,0 +1,90 @@ +/// Represents the possible statuses of a verification job. +enum VerificationStatus { + /// Job is created and waiting to be processed. + pending('PENDING'), + + /// Job is currently being processed by machine or human. + processing('PROCESSING'), + + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const VerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [VerificationStatus] from a string. + static VerificationStatus fromString(String value) { + return VerificationStatus.values.firstWhere( + (VerificationStatus e) => e.value == value, + orElse: () => VerificationStatus.error, + ); + } +} + +/// Response model for verification operations. +class VerificationResponse { + /// Creates a [VerificationResponse]. + const VerificationResponse({ + required this.verificationId, + required this.status, + this.type, + this.review, + this.requestId, + }); + + /// Factory to create [VerificationResponse] from JSON. + factory VerificationResponse.fromJson(Map json) { + return VerificationResponse( + verificationId: json['verificationId'] as String, + status: VerificationStatus.fromString(json['status'] as String), + type: json['type'] as String?, + review: json['review'] != null + ? json['review'] as Map + : null, + requestId: json['requestId'] as String?, + ); + } + + /// The unique ID of the verification job. + final String verificationId; + + /// Current status of the verification. + final VerificationStatus status; + + /// The type of verification (e.g., attire, government_id). + final String? type; + + /// Optional human review details. + final Map? review; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'verificationId': verificationId, + 'status': status.value, + 'type': type, + 'review': review, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart new file mode 100644 index 00000000..73390819 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -0,0 +1,94 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'verification_response.dart'; + +/// Service for handling async verification jobs. +class VerificationService extends BaseCoreService { + /// Creates a [VerificationService]. + VerificationService(super.api); + + /// Enqueues a new verification job. + /// + /// [type] can be 'attire', 'government_id', etc. + /// [subjectType] is usually 'worker'. + /// [fileUri] is the gs:// path of the uploaded file. + Future createVerification({ + required String type, + required String subjectType, + required String subjectId, + required String fileUri, + Map? rules, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verifications, + data: { + 'type': type, + 'subjectType': subjectType, + 'subjectId': subjectId, + 'fileUri': fileUri, + if (rules != null) 'rules': rules, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Polls the status of a specific verification. + Future getStatus(String verificationId) async { + final ApiResponse res = await action(() async { + return api.get(CoreApiEndpoints.verificationStatus(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Submits a manual review decision. + /// + /// [decision] should be 'APPROVED' or 'REJECTED'. + Future reviewVerification({ + required String verificationId, + required String decision, + String? note, + String? reasonCode, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verificationReview(verificationId), + data: { + 'decision': decision, + if (note != null) 'note': note, + if (reasonCode != null) 'reasonCode': reasonCode, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Retries a verification job that failed or needs re-processing. + Future retryVerification(String verificationId) async { + final ApiResponse res = await action(() async { + return api.post(CoreApiEndpoints.verificationRetry(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart new file mode 100644 index 00000000..e035ae18 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -0,0 +1,27 @@ +import 'package:dio/dio.dart'; +import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; + +/// A custom Dio client for the Krow project that includes basic configuration +/// and an [AuthInterceptor]. +class DioClient extends DioMixin implements Dio { + DioClient([BaseOptions? baseOptions]) { + options = + baseOptions ?? + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ); + + // Use the default adapter + httpClientAdapter = HttpClientAdapter(); + + // Add interceptors + interceptors.addAll([ + AuthInterceptor(), + LogInterceptor( + requestBody: true, + responseBody: true, + ), // Added for better debugging + ]); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart new file mode 100644 index 00000000..d6974e57 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// An interceptor that adds the Firebase Auth ID token to the Authorization header. +class AuthInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + try { + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } catch (e) { + rethrow; + } + } + return handler.next(options); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart new file mode 100644 index 00000000..c7317aa4 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for capturing photos and videos using the device camera. +class CameraService extends BaseDeviceService { + /// Creates a [CameraService]. + CameraService(ImagePicker picker) : _picker = picker; + + final ImagePicker _picker; + + /// Captures a photo using the camera. + /// + /// Returns the path to the captured image, or null if cancelled. + Future takePhoto() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart new file mode 100644 index 00000000..55321461 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart @@ -0,0 +1,22 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking files from the device filesystem. +class FilePickerService extends BaseDeviceService { + /// Creates a [FilePickerService]. + const FilePickerService(); + + /// Picks a single file from the device. + /// + /// Returns the path to the selected file, or null if cancelled. + Future pickFile({List? allowedExtensions}) async { + return action(() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: allowedExtensions != null ? FileType.custom : FileType.any, + allowedExtensions: allowedExtensions, + ); + + return result?.files.single.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart new file mode 100644 index 00000000..4fea7e77 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart @@ -0,0 +1,60 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../camera/camera_service.dart'; +import '../gallery/gallery_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_response.dart'; + +/// Orchestrator service that combines device picking and network uploading. +/// +/// This provides a simplified entry point for features to "pick and upload" +/// in a single call. +class DeviceFileUploadService extends BaseDeviceService { + /// Creates a [DeviceFileUploadService]. + DeviceFileUploadService({ + required this.cameraService, + required this.galleryService, + required this.apiUploadService, + }); + + final CameraService cameraService; + final GalleryService galleryService; + final FileUploadService apiUploadService; + + /// Captures a photo from the camera and uploads it immediately. + Future uploadFromCamera({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await cameraService.takePhoto(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } + + /// Picks an image from the gallery and uploads it immediately. + Future uploadFromGallery({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await galleryService.pickImage(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart new file mode 100644 index 00000000..7667e73d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking media from the device gallery. +class GalleryService extends BaseDeviceService { + /// Creates a [GalleryService]. + GalleryService(this._picker); + + final ImagePicker _picker; + + /// Picks an image from the gallery. + /// + /// Returns the path to the selected image, or null if cancelled. + Future pickImage() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 80bacabe..08ec902f 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -11,10 +11,18 @@ environment: dependencies: flutter: sdk: flutter - flutter_bloc: ^8.1.0 - design_system: - path: ../design_system - equatable: ^2.0.8 - flutter_modular: ^6.4.1 + + # internal packages krow_domain: path: ../domain + design_system: + path: ../design_system + + flutter_bloc: ^8.1.0 + equatable: ^2.0.8 + flutter_modular: ^6.4.1 + dio: ^5.9.1 + image_picker: ^1.1.2 + path_provider: ^2.1.3 + file_picker: ^8.1.7 + firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 75fcb168..bd3e4341 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -208,6 +208,7 @@ "edit_profile": "Edit Profile", "hubs": "Hubs", "log_out": "Log Out", + "log_out_confirmation": "Are you sure you want to log out?", "quick_links": "Quick Links", "clock_in_hubs": "Clock-In Hubs", "billing_payments": "Billing & Payments" @@ -252,6 +253,11 @@ "location_hint": "e.g., Downtown Restaurant", "address_label": "Address", "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", + "address_required": "Address is required", "create_button": "Create Hub" }, "edit_hub": { @@ -261,8 +267,14 @@ "name_hint": "e.g., Main Kitchen, Front Desk", "address_label": "Address", "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", "save_button": "Save Changes", - "success": "Hub updated successfully!" + "success": "Hub updated successfully!", + "created_success": "Hub created successfully", + "updated_success": "Hub updated successfully" }, "hub_details": { "title": "Hub Details", @@ -270,7 +282,10 @@ "address_label": "Address", "nfc_label": "NFC Tag", "nfc_not_assigned": "Not Assigned", - "edit_button": "Edit Hub" + "cost_center_label": "Cost Center", + "cost_center_none": "Not Assigned", + "edit_button": "Edit Hub", + "deleted_success": "Hub deleted successfully" }, "nfc_dialog": { "title": "Identify NFC Tag", @@ -326,6 +341,11 @@ "date_hint": "Select date", "location_label": "Location", "location_hint": "Enter address", + "hub_manager_label": "Shift Contact", + "hub_manager_desc": "On-site manager or supervisor for this shift", + "hub_manager_hint": "Select Contact", + "hub_manager_empty": "No hub managers available", + "hub_manager_none": "None", "positions_title": "Positions", "add_position": "Add Position", "position_number": "Position $number", @@ -377,6 +397,41 @@ "active": "Active", "completed": "Completed" }, + "order_edit_sheet": { + "title": "Edit Your Order", + "vendor_section": "VENDOR", + "location_section": "LOCATION", + "shift_contact_section": "SHIFT CONTACT", + "shift_contact_desc": "On-site manager or supervisor for this shift", + "select_contact": "Select Contact", + "no_hub_managers": "No hub managers available", + "none": "None", + "positions_section": "POSITIONS", + "add_position": "Add Position", + "review_positions": "Review $count Positions", + "order_name_hint": "Order name", + "remove": "Remove", + "select_role_hint": "Select role", + "start_label": "Start", + "end_label": "End", + "workers_label": "Workers", + "different_location": "Use different location for this position", + "different_location_title": "Different Location", + "enter_address_hint": "Enter different address", + "no_break": "No Break", + "positions": "Positions", + "workers": "Workers", + "est_cost": "Est. Cost", + "positions_breakdown": "Positions Breakdown", + "edit_button": "Edit", + "confirm_save": "Confirm & Save", + "position_singular": "Position", + "order_updated_title": "Order Updated!", + "order_updated_message": "Your shift has been updated successfully.", + "back_to_orders": "Back to Orders", + "one_time_order_title": "One-Time Order", + "refine_subtitle": "Refine your staffing needs" + }, "card": { "open": "OPEN", "filled": "FILLED", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index b3a1148e..076a4da6 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -208,6 +208,7 @@ "edit_profile": "Editar Perfil", "hubs": "Hubs", "log_out": "Cerrar sesi\u00f3n", + "log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?", "quick_links": "Enlaces r\u00e1pidos", "clock_in_hubs": "Hubs de Marcaje", "billing_payments": "Facturaci\u00f3n y Pagos" @@ -252,6 +253,11 @@ "location_hint": "ej., Restaurante Centro", "address_label": "Direcci\u00f3n", "address_hint": "Direcci\u00f3n completa", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "Nombre es obligatorio", + "address_required": "La direcci\u00f3n es obligatoria", "create_button": "Crear Hub" }, "nfc_dialog": { @@ -276,8 +282,14 @@ "name_hint": "Ingresar nombre del hub", "address_label": "Direcci\u00f3n", "address_hint": "Ingresar direcci\u00f3n", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "El nombre es obligatorio", "save_button": "Guardar Cambios", - "success": "\u00a1Hub actualizado exitosamente!" + "success": "\u00a1Hub actualizado exitosamente!", + "created_success": "Hub creado exitosamente", + "updated_success": "Hub actualizado exitosamente" }, "hub_details": { "title": "Detalles del Hub", @@ -285,7 +297,10 @@ "name_label": "Nombre del Hub", "address_label": "Direcci\u00f3n", "nfc_label": "Etiqueta NFC", - "nfc_not_assigned": "No asignada" + "nfc_not_assigned": "No asignada", + "cost_center_label": "Centro de Costos", + "cost_center_none": "No asignado", + "deleted_success": "Hub eliminado exitosamente" } }, "client_create_order": { @@ -326,6 +341,11 @@ "date_hint": "Seleccionar fecha", "location_label": "Ubicaci\u00f3n", "location_hint": "Ingresar direcci\u00f3n", + "hub_manager_label": "Contacto del Turno", + "hub_manager_desc": "Gerente o supervisor en el sitio para este turno", + "hub_manager_hint": "Seleccionar Contacto", + "hub_manager_empty": "No hay contactos de turno disponibles", + "hub_manager_none": "Ninguno", "positions_title": "Posiciones", "add_position": "A\u00f1adir Posici\u00f3n", "position_number": "Posici\u00f3n $number", @@ -377,6 +397,41 @@ "active": "Activos", "completed": "Completados" }, + "order_edit_sheet": { + "title": "Editar Tu Orden", + "vendor_section": "PROVEEDOR", + "location_section": "UBICACI\u00d3N", + "shift_contact_section": "CONTACTO DEL TURNO", + "shift_contact_desc": "Gerente o supervisor en el sitio para este turno", + "select_contact": "Seleccionar Contacto", + "no_hub_managers": "No hay contactos de turno disponibles", + "none": "Ninguno", + "positions_section": "POSICIONES", + "add_position": "A\u00f1adir Posici\u00f3n", + "review_positions": "Revisar $count Posiciones", + "order_name_hint": "Nombre de la orden", + "remove": "Eliminar", + "select_role_hint": "Seleccionar rol", + "start_label": "Inicio", + "end_label": "Fin", + "workers_label": "Trabajadores", + "different_location": "Usar ubicaci\u00f3n diferente para esta posici\u00f3n", + "different_location_title": "Ubicaci\u00f3n Diferente", + "enter_address_hint": "Ingresar direcci\u00f3n diferente", + "no_break": "Sin Descanso", + "positions": "Posiciones", + "workers": "Trabajadores", + "est_cost": "Costo Est.", + "positions_breakdown": "Desglose de Posiciones", + "edit_button": "Editar", + "confirm_save": "Confirmar y Guardar", + "position_singular": "Posici\u00f3n", + "order_updated_title": "\u00a1Orden Actualizada!", + "order_updated_message": "Tu turno ha sido actualizado exitosamente.", + "back_to_orders": "Volver a \u00d3rdenes", + "one_time_order_title": "Orden \u00danica Vez", + "refine_subtitle": "Ajusta tus necesidades de personal" + }, "card": { "open": "ABIERTO", "filled": "LLENO", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart index bc317ea9..c046918c 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart @@ -1,4 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'dart:convert'; import 'package:firebase_data_connect/src/core/ref.dart'; import 'package:http/http.dart' as http; @@ -23,7 +23,25 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { .getTeamHubsByTeamId(teamId: teamId) .execute(); + final QueryResult< + dc.ListTeamHudDepartmentsData, + dc.ListTeamHudDepartmentsVariables + > + deptsResult = await _service.connector.listTeamHudDepartments().execute(); + final Map hubToDept = + {}; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in deptsResult.data.teamHudDepartments) { + if (dep.costCenter != null && + dep.costCenter!.isNotEmpty && + !hubToDept.containsKey(dep.teamHubId)) { + hubToDept[dep.teamHubId] = dep; + } + } + return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) { + final dc.ListTeamHudDepartmentsTeamHudDepartments? dept = + hubToDept[h.id]; return Hub( id: h.id, businessId: businessId, @@ -31,6 +49,13 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { address: h.address, nfcTagId: null, status: h.isActive ? HubStatus.active : HubStatus.inactive, + costCenter: dept != null + ? CostCenter( + id: dept.id, + name: dept.name, + code: dept.costCenter ?? dept.name, + ) + : null, ); }).toList(); }); @@ -49,6 +74,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }) async { return _service.run(() async { final String teamId = await _getOrCreateTeamId(businessId); @@ -72,13 +98,27 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { .zipCode(zipCode ?? placeAddress?.zipCode) .execute(); + final String hubId = result.data.teamHub_insert.id; + CostCenter? costCenter; + if (costCenterId != null && costCenterId.isNotEmpty) { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: hubId, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + return Hub( - id: result.data.teamHub_insert.id, + id: hubId, businessId: businessId, name: name, address: address, nfcTagId: null, status: HubStatus.active, + costCenter: costCenter, ); }); } @@ -97,6 +137,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }) async { return _service.run(() async { final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) @@ -128,7 +169,43 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { await builder.execute(); - // Return a basic hub object reflecting changes (or we could re-fetch) + CostCenter? costCenter; + final QueryResult< + dc.ListTeamHudDepartmentsByTeamHubIdData, + dc.ListTeamHudDepartmentsByTeamHubIdVariables + > + deptsResult = await _service.connector + .listTeamHudDepartmentsByTeamHubId(teamHubId: id) + .execute(); + final List depts = + deptsResult.data.teamHudDepartments; + + if (costCenterId == null || costCenterId.isEmpty) { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(null) + .execute(); + } + } else { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } else { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: id, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + } + return Hub( id: id, businessId: businessId, @@ -136,6 +213,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { address: address ?? '', nfcTagId: null, status: HubStatus.active, + costCenter: costCenter, ); }); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart index 28e10e3d..42a83265 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart @@ -20,6 +20,7 @@ abstract interface class HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Updates an existing hub. @@ -36,6 +37,7 @@ abstract interface class HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Deletes a hub. diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 9cdf0888..24f01a00 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,8 +1,7 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' - hide AttireVerificationStatus; -import 'package:krow_domain/krow_domain.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/repositories/staff_connector_repository.dart'; /// Implementation of [StaffConnectorRepository]. /// @@ -12,10 +11,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { /// Creates a new [StaffConnectorRepositoryImpl]. /// /// Requires a [DataConnectService] instance for backend communication. - StaffConnectorRepositoryImpl({DataConnectService? service}) - : _service = service ?? DataConnectService.instance; + StaffConnectorRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; - final DataConnectService _service; + final dc.DataConnectService _service; @override Future getProfileCompletion() async { @@ -23,17 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffProfileCompletionData, - GetStaffProfileCompletionVariables + dc.GetStaffProfileCompletionData, + dc.GetStaffProfileCompletionVariables > response = await _service.connector .getStaffProfileCompletion(id: staffId) .execute(); - final GetStaffProfileCompletionStaff? staff = response.data.staff; - final List emergencyContacts = - response.data.emergencyContacts; - final List taxForms = + final dc.GetStaffProfileCompletionStaff? staff = response.data.staff; + final List + emergencyContacts = response.data.emergencyContacts; + final List taxForms = response.data.taxForms; return _isProfileComplete(staff, emergencyContacts, taxForms); @@ -46,15 +45,14 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffPersonalInfoCompletionData, - GetStaffPersonalInfoCompletionVariables + dc.GetStaffPersonalInfoCompletionData, + dc.GetStaffPersonalInfoCompletionVariables > response = await _service.connector .getStaffPersonalInfoCompletion(id: staffId) .execute(); - final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; - + final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; return _isPersonalInfoComplete(staff); }); } @@ -65,8 +63,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffEmergencyProfileCompletionData, - GetStaffEmergencyProfileCompletionVariables + dc.GetStaffEmergencyProfileCompletionData, + dc.GetStaffEmergencyProfileCompletionVariables > response = await _service.connector .getStaffEmergencyProfileCompletion(id: staffId) @@ -82,16 +80,15 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffExperienceProfileCompletionData, - GetStaffExperienceProfileCompletionVariables + dc.GetStaffExperienceProfileCompletionData, + dc.GetStaffExperienceProfileCompletionVariables > response = await _service.connector .getStaffExperienceProfileCompletion(id: staffId) .execute(); - final GetStaffExperienceProfileCompletionStaff? staff = + final dc.GetStaffExperienceProfileCompletionStaff? staff = response.data.staff; - return _hasExperience(staff); }); } @@ -102,8 +99,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffTaxFormsProfileCompletionData, - GetStaffTaxFormsProfileCompletionVariables + dc.GetStaffTaxFormsProfileCompletionData, + dc.GetStaffTaxFormsProfileCompletionVariables > response = await _service.connector .getStaffTaxFormsProfileCompletion(id: staffId) @@ -114,148 +111,162 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { } /// Checks if personal info is complete. - bool _isPersonalInfoComplete(GetStaffPersonalInfoCompletionStaff? staff) { + bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) { if (staff == null) return false; final String fullName = staff.fullName; final String? email = staff.email; final String? phone = staff.phone; - return (fullName.trim().isNotEmpty ?? false) && + return fullName.trim().isNotEmpty && (email?.trim().isNotEmpty ?? false) && (phone?.trim().isNotEmpty ?? false); } /// Checks if staff has experience data (skills or industries). - bool _hasExperience(GetStaffExperienceProfileCompletionStaff? staff) { + bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) { if (staff == null) return false; - final dynamic skills = staff.skills; - final dynamic industries = staff.industries; - return (skills is List && skills.isNotEmpty) || - (industries is List && industries.isNotEmpty); + final List? skills = staff.skills; + final List? industries = staff.industries; + return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); } /// Determines if the profile is complete based on all sections. bool _isProfileComplete( - GetStaffProfileCompletionStaff? staff, - List emergencyContacts, - List taxForms, + dc.GetStaffProfileCompletionStaff? staff, + List emergencyContacts, + List taxForms, ) { if (staff == null) return false; - final dynamic skills = staff.skills; - final dynamic industries = staff.industries; + + final List? skills = staff.skills; + final List? industries = staff.industries; final bool hasExperience = - (skills is List && skills.isNotEmpty) || - (industries is List && industries.isNotEmpty); - return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience; + (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); + + return (staff.fullName.trim().isNotEmpty) && + (staff.email?.trim().isNotEmpty ?? false) && + emergencyContacts.isNotEmpty && + taxForms.isNotEmpty && + hasExperience; } @override - Future getStaffProfile() async { + Future getStaffProfile() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector.getStaffById(id: staffId).execute(); + final QueryResult + response = await _service.connector.getStaffById(id: staffId).execute(); - if (response.data.staff == null) { - throw const ServerException(technicalMessage: 'Staff not found'); + final dc.GetStaffByIdStaff? staff = response.data.staff; + + if (staff == null) { + throw Exception('Staff not found'); } - final GetStaffByIdStaff rawStaff = response.data.staff!; - - // Map the raw data connect object to the Domain Entity - return Staff( - id: rawStaff.id, - authProviderId: rawStaff.userId, - name: rawStaff.fullName, - email: rawStaff.email ?? '', - phone: rawStaff.phone, - avatar: rawStaff.photoUrl, - status: StaffStatus.active, - address: rawStaff.addres, - totalShifts: rawStaff.totalShifts, - averageRating: rawStaff.averageRating, - onTimeRate: rawStaff.onTimeRate, - noShowCount: rawStaff.noShowCount, - cancellationCount: rawStaff.cancellationCount, - reliabilityScore: rawStaff.reliabilityScore, + return domain.Staff( + id: staff.id, + authProviderId: staff.userId, + name: staff.fullName, + email: staff.email ?? '', + phone: staff.phone, + avatar: staff.photoUrl, + status: domain.StaffStatus.active, + address: staff.addres, + totalShifts: staff.totalShifts, + averageRating: staff.averageRating, + onTimeRate: staff.onTimeRate, + noShowCount: staff.noShowCount, + cancellationCount: staff.cancellationCount, + reliabilityScore: staff.reliabilityScore, ); }); } @override - Future> getBenefits() async { + Future> getBenefits() async { return _service.run(() async { final String staffId = await _service.getStaffId(); final QueryResult< - ListBenefitsDataByStaffIdData, - ListBenefitsDataByStaffIdVariables + dc.ListBenefitsDataByStaffIdData, + dc.ListBenefitsDataByStaffIdVariables > response = await _service.connector .listBenefitsDataByStaffId(staffId: staffId) .execute(); - return response.data.benefitsDatas.map((data) { - final plan = data.vendorBenefitPlan; - return Benefit( - title: plan.title, - entitlementHours: plan.total?.toDouble() ?? 0.0, - usedHours: data.current.toDouble(), - ); - }).toList(); + return response.data.benefitsDatas + .map( + (dc.ListBenefitsDataByStaffIdBenefitsDatas e) => domain.Benefit( + title: e.vendorBenefitPlan.title, + entitlementHours: e.vendorBenefitPlan.total?.toDouble() ?? 0, + usedHours: e.current.toDouble(), + ), + ) + .toList(); }); } @override - Future> getAttireOptions() async { + Future> getAttireOptions() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - // Fetch all options - final QueryResult optionsResponse = - await _service.connector.listAttireOptions().execute(); + final List> results = + await Future.wait>( + >>[ + _service.connector.listAttireOptions().execute(), + _service.connector.getStaffAttire(staffId: staffId).execute(), + ], + ); - // Fetch user's attire status - final QueryResult - attiresResponse = await _service.connector - .getStaffAttire(staffId: staffId) - .execute(); + final QueryResult optionsRes = + results[0] as QueryResult; + final QueryResult + staffAttireRes = + results[1] + as QueryResult; - final Map attireMap = { - for (final item in attiresResponse.data.staffAttires) - item.attireOptionId: item, - }; + final List staffAttire = + staffAttireRes.data.staffAttires; - return optionsResponse.data.attireOptions.map((e) { - final GetStaffAttireStaffAttires? userAttire = attireMap[e.id]; - return AttireItem( - id: e.itemId, - label: e.label, - description: e.description, - imageUrl: e.imageUrl, - isMandatory: e.isMandatory ?? false, - verificationStatus: _mapAttireStatus( - userAttire?.verificationStatus?.stringValue, - ), - photoUrl: userAttire?.verificationPhotoUrl, + return optionsRes.data.attireOptions.map(( + dc.ListAttireOptionsAttireOptions opt, + ) { + final dc.GetStaffAttireStaffAttires currentAttire = staffAttire + .firstWhere( + (dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id, + orElse: () => dc.GetStaffAttireStaffAttires( + attireOptionId: opt.id, + verificationPhotoUrl: null, + verificationId: null, + verificationStatus: null, + ), + ); + + return domain.AttireItem( + id: opt.id, + code: opt.itemId, + label: opt.label, + description: opt.description, + imageUrl: opt.imageUrl, + isMandatory: opt.isMandatory ?? false, + photoUrl: currentAttire.verificationPhotoUrl, + verificationId: currentAttire.verificationId, + verificationStatus: currentAttire.verificationStatus != null + ? _mapFromDCStatus(currentAttire.verificationStatus!) + : null, ); }).toList(); }); } - AttireVerificationStatus? _mapAttireStatus(String? status) { - if (status == null) return null; - return AttireVerificationStatus.values.firstWhere( - (e) => e.name.toUpperCase() == status.toUpperCase(), - orElse: () => AttireVerificationStatus.pending, - ); - } - @override Future upsertStaffAttire({ required String attireOptionId, required String photoUrl, String? verificationId, + domain.AttireVerificationStatus? verificationStatus, }) async { await _service.run(() async { final String staffId = await _service.getStaffId(); @@ -263,7 +274,68 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { await _service.connector .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) .verificationPhotoUrl(photoUrl) - // .verificationId(verificationId) // Uncomment after SDK regeneration + .verificationId(verificationId) + .verificationStatus( + verificationStatus != null + ? dc.AttireVerificationStatus.values.firstWhere( + (dc.AttireVerificationStatus e) => + e.name == verificationStatus.value.toUpperCase(), + orElse: () => dc.AttireVerificationStatus.PENDING, + ) + : null, + ) + .execute(); + }); + } + + domain.AttireVerificationStatus _mapFromDCStatus( + dc.EnumValue status, + ) { + if (status is dc.Unknown) { + return domain.AttireVerificationStatus.error; + } + final String name = + (status as dc.Known).value.name; + switch (name) { + case 'PENDING': + return domain.AttireVerificationStatus.pending; + case 'PROCESSING': + return domain.AttireVerificationStatus.processing; + case 'AUTO_PASS': + return domain.AttireVerificationStatus.autoPass; + case 'AUTO_FAIL': + return domain.AttireVerificationStatus.autoFail; + case 'NEEDS_REVIEW': + return domain.AttireVerificationStatus.needsReview; + case 'APPROVED': + return domain.AttireVerificationStatus.approved; + case 'REJECTED': + return domain.AttireVerificationStatus.rejected; + case 'ERROR': + return domain.AttireVerificationStatus.error; + default: + return domain.AttireVerificationStatus.error; + } + } + + @override + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }) async { + await _service.run(() async { + final String staffId = await _service.getStaffId(); + final String? fullName = (firstName != null || lastName != null) + ? '${firstName ?? ''} ${lastName ?? ''}'.trim() + : null; + + await _service.connector + .updateStaff(id: staffId) + .fullName(fullName) + .bio(bio) + .photoUrl(profilePictureUrl) .execute(); }); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index e4cc2db8..3bd3c9e7 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -55,6 +55,7 @@ abstract interface class StaffConnectorRepository { required String attireOptionId, required String photoUrl, String? verificationId, + AttireVerificationStatus? verificationStatus, }); /// Signs out the current user. @@ -63,4 +64,12 @@ abstract interface class StaffConnectorRepository { /// /// Throws an exception if the sign-out fails. Future signOut(); + + /// Saves the staff profile information. + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }); } diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart index 9ae7ff61..705e4542 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_text_field.dart @@ -11,6 +11,7 @@ class UiTextField extends StatelessWidget { const UiTextField({ super.key, + this.semanticsIdentifier, this.label, this.hintText, this.onChanged, @@ -29,6 +30,8 @@ class UiTextField extends StatelessWidget { this.onTap, this.validator, }); + /// Optional semantics identifier for E2E testing (e.g. Maestro). + final String? semanticsIdentifier; /// The label text to display above the text field. final String? label; @@ -90,7 +93,9 @@ class UiTextField extends StatelessWidget { Text(label!, style: UiTypography.body4m.textSecondary), const SizedBox(height: UiConstants.space1), ], - TextFormField( + Builder( + builder: (BuildContext context) { + final Widget field = TextFormField( controller: controller, onChanged: onChanged, keyboardType: keyboardType, @@ -113,6 +118,15 @@ class UiTextField extends StatelessWidget { ? Icon(suffixIcon, size: 20, color: UiColors.iconSecondary) : suffix, ), + ); + if (semanticsIdentifier != null) { + return Semantics( + identifier: semanticsIdentifier!, + child: field, + ); + } + return field; + }, ), ], ); diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 9c67574f..87167b9e 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -6,6 +6,15 @@ /// Note: Repository Interfaces are now located in their respective Feature packages. library; +// Core +export 'src/core/services/api_services/api_response.dart'; +export 'src/core/services/api_services/base_api_service.dart'; +export 'src/core/services/api_services/base_core_service.dart'; +export 'src/core/services/api_services/file_visibility.dart'; + +// Device +export 'src/core/services/device/base_device_service.dart'; + // Users & Membership export 'src/entities/users/user.dart'; export 'src/entities/users/staff.dart'; @@ -19,6 +28,7 @@ export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; export 'src/entities/business/hub_department.dart'; export 'src/entities/business/vendor.dart'; +export 'src/entities/business/cost_center.dart'; // Events & Assignments export 'src/entities/events/event.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart new file mode 100644 index 00000000..3e6a5435 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart @@ -0,0 +1,22 @@ +/// Represents a standardized response from the API. +class ApiResponse { + /// Creates an [ApiResponse]. + const ApiResponse({ + required this.code, + required this.message, + this.data, + this.errors = const {}, + }); + + /// The response code (e.g., '200', '404', or custom error code). + final String code; + + /// A descriptive message about the response. + final String message; + + /// The payload returned by the API. + final dynamic data; + + /// A map of field-specific error messages, if any. + final Map errors; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart new file mode 100644 index 00000000..ef9ccef6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart @@ -0,0 +1,30 @@ +import 'api_response.dart'; + +/// Abstract base class for API services. +/// +/// This defines the contract for making HTTP requests. +abstract class BaseApiService { + /// Performs a GET request to the specified [endpoint]. + Future get(String endpoint, {Map? params}); + + /// Performs a POST request to the specified [endpoint]. + Future post( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PUT request to the specified [endpoint]. + Future put( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PATCH request to the specified [endpoint]. + Future patch( + String endpoint, { + dynamic data, + Map? params, + }); +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart new file mode 100644 index 00000000..1acda2e3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart @@ -0,0 +1,29 @@ +import 'api_response.dart'; +import 'base_api_service.dart'; + +/// Abstract base class for core business services. +/// +/// This provides a common [action] wrapper for standardized execution +/// and error catching across all core service implementations. +abstract class BaseCoreService { + /// Creates a [BaseCoreService] with the given [api] client. + const BaseCoreService(this.api); + + /// The API client used to perform requests. + final BaseApiService api; + + /// Standardized wrapper to execute API actions. + /// + /// This handles generic error normalization for unexpected non-HTTP errors. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + return ApiResponse( + code: 'CORE_INTERNAL_ERROR', + message: e.toString(), + errors: {'exception': e.runtimeType.toString()}, + ); + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart new file mode 100644 index 00000000..2b0d7dd0 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart @@ -0,0 +1,14 @@ +/// Represents the accessibility level of an uploaded file. +enum FileVisibility { + /// File is accessible only to authenticated owners/authorized users. + private('private'), + + /// File is accessible publicly via its URL. + public('public'); + + /// Creates a [FileVisibility]. + const FileVisibility(this.value); + + /// The string value expected by the backend. + final String value; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart new file mode 100644 index 00000000..b8f030fc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart @@ -0,0 +1,22 @@ +/// Abstract base class for device-related services. +/// +/// Device services handle native hardware/platform interactions +/// like Camera, Gallery, Location, or Biometrics. +abstract class BaseDeviceService { + const BaseDeviceService(); + + /// Standardized wrapper to execute device actions. + /// + /// This can be used for common handling like logging device interactions + /// or catching native platform exceptions. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + // Re-throw or handle based on project preference. + // For device services, we might want to throw specific + // DeviceExceptions later. + rethrow; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart new file mode 100644 index 00000000..8d3d5528 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a financial cost center used for billing and tracking. +class CostCenter extends Equatable { + const CostCenter({ + required this.id, + required this.name, + this.code, + }); + + /// Unique identifier. + final String id; + + /// Display name of the cost center. + final String name; + + /// Optional alphanumeric code associated with this cost center. + final String? code; + + @override + List get props => [id, name, code]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart index 4070a28a..79c06572 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'cost_center.dart'; + /// The status of a [Hub]. enum HubStatus { /// Fully operational. @@ -14,7 +16,6 @@ enum HubStatus { /// Represents a branch location or operational unit within a [Business]. class Hub extends Equatable { - const Hub({ required this.id, required this.businessId, @@ -22,6 +23,7 @@ class Hub extends Equatable { required this.address, this.nfcTagId, required this.status, + this.costCenter, }); /// Unique identifier. final String id; @@ -41,6 +43,9 @@ class Hub extends Equatable { /// Operational status. final HubStatus status; + /// Assigned cost center for this hub. + final CostCenter? costCenter; + @override - List get props => [id, businessId, name, address, nfcTagId, status]; + List get props => [id, businessId, name, address, nfcTagId, status, costCenter]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart index e0e7ca67..fe50bd20 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart @@ -13,6 +13,7 @@ class OneTimeOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); /// The specific date for the shift or event. @@ -33,6 +34,9 @@ class OneTimeOrder extends Equatable { /// Selected vendor id for this order. final String? vendorId; + /// Optional hub manager id. + final String? hubManagerId; + /// Role hourly rates keyed by role id. final Map roleRates; @@ -44,6 +48,7 @@ class OneTimeOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index b9ab956f..88ae8091 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -27,6 +27,8 @@ class OrderItem extends Equatable { this.hours = 0, this.totalValue = 0, this.confirmedApps = const >[], + this.hubManagerId, + this.hubManagerName, }); /// Unique identifier of the order. @@ -83,6 +85,12 @@ class OrderItem extends Equatable { /// List of confirmed worker applications. final List> confirmedApps; + /// Optional ID of the assigned hub manager. + final String? hubManagerId; + + /// Optional Name of the assigned hub manager. + final String? hubManagerName; + @override List get props => [ id, @@ -103,5 +111,7 @@ class OrderItem extends Equatable { totalValue, eventName, confirmedApps, + hubManagerId, + hubManagerName, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart index da4feb71..ef950f87 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -11,6 +11,7 @@ class PermanentOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); @@ -23,6 +24,7 @@ class PermanentOrder extends Equatable { final OneTimeOrderHubDetails? hub; final String? eventName; final String? vendorId; + final String? hubManagerId; final Map roleRates; @override @@ -33,6 +35,7 @@ class PermanentOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart index f11b63ec..76f00720 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -12,6 +12,7 @@ class RecurringOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); @@ -39,6 +40,9 @@ class RecurringOrder extends Equatable { /// Selected vendor id for this order. final String? vendorId; + /// Optional hub manager id. + final String? hubManagerId; + /// Role hourly rates keyed by role id. final Map roleRates; @@ -52,6 +56,7 @@ class RecurringOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart index d830add4..d794ca9e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart @@ -9,6 +9,7 @@ class AttireItem extends Equatable { /// Creates an [AttireItem]. const AttireItem({ required this.id, + required this.code, required this.label, this.description, this.imageUrl, @@ -18,9 +19,12 @@ class AttireItem extends Equatable { this.verificationId, }); - /// Unique identifier of the attire item. + /// Unique identifier of the attire item (UUID). final String id; + /// String code for the attire item (e.g. BLACK_TSHIRT). + final String code; + /// Display name of the item. final String label; @@ -45,6 +49,7 @@ class AttireItem extends Equatable { @override List get props => [ id, + code, label, description, imageUrl, @@ -53,4 +58,29 @@ class AttireItem extends Equatable { photoUrl, verificationId, ]; + + /// Creates a copy of this [AttireItem] with the given fields replaced. + AttireItem copyWith({ + String? id, + String? code, + String? label, + String? description, + String? imageUrl, + bool? isMandatory, + AttireVerificationStatus? verificationStatus, + String? photoUrl, + String? verificationId, + }) { + return AttireItem( + id: id ?? this.id, + code: code ?? this.code, + label: label ?? this.label, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + isMandatory: isMandatory ?? this.isMandatory, + verificationStatus: verificationStatus ?? this.verificationStatus, + photoUrl: photoUrl ?? this.photoUrl, + verificationId: verificationId ?? this.verificationId, + ); + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart index bc5a3430..f766e8dc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart @@ -1,11 +1,39 @@ /// Represents the verification status of an attire item photo. enum AttireVerificationStatus { - /// The photo is waiting for review. - pending, + /// Job is created and waiting to be processed. + pending('PENDING'), - /// The photo was rejected. - failed, + /// Job is currently being processed by machine or human. + processing('PROCESSING'), - /// The photo was approved. - success, + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const AttireVerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [AttireVerificationStatus] from a string. + static AttireVerificationStatus fromString(String value) { + return AttireVerificationStatus.values.firstWhere( + (AttireVerificationStatus e) => e.value == value, + orElse: () => AttireVerificationStatus.error, + ); + } } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart index 129fc662..9a3d4c3b 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_in_page/client_sign_in_form.dart @@ -52,6 +52,7 @@ class _ClientSignInFormState extends State { children: [ // Email Field UiTextField( + semanticsIdentifier: 'sign_in_email', label: i18n.email_label, hintText: i18n.email_hint, controller: _emailController, @@ -61,6 +62,7 @@ class _ClientSignInFormState extends State { // Password Field UiTextField( + semanticsIdentifier: 'sign_in_password', label: i18n.password_label, hintText: i18n.password_hint, controller: _passwordController, diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart index b6617bdc..2bf0f0a0 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/widgets/client_sign_up_page/client_sign_up_form.dart @@ -70,6 +70,7 @@ class _ClientSignUpFormState extends State { children: [ // Company Name Field UiTextField( + semanticsIdentifier: 'sign_up_company', label: i18n.company_label, hintText: i18n.company_hint, controller: _companyController, @@ -79,6 +80,7 @@ class _ClientSignUpFormState extends State { // Email Field UiTextField( + semanticsIdentifier: 'sign_up_email', label: i18n.email_label, hintText: i18n.email_hint, controller: _emailController, @@ -89,6 +91,7 @@ class _ClientSignUpFormState extends State { // Password Field UiTextField( + semanticsIdentifier: 'sign_up_password', label: i18n.password_label, hintText: i18n.password_hint, controller: _passwordController, @@ -108,6 +111,7 @@ class _ClientSignUpFormState extends State { // Confirm Password Field UiTextField( + semanticsIdentifier: 'sign_up_confirm_password', label: i18n.confirm_password_label, hintText: i18n.confirm_password_hint, controller: _confirmPasswordController, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart index cc455c67..271fda78 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart @@ -46,7 +46,7 @@ class SavingsCard extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( // Using a hardcoded 180 here to match prototype mock or derived value - t.client_billing.rate_optimization_body(amount: 180), + "180", style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space2), diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 49a88f20..53fdb2e4 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -1,5 +1,6 @@ library; +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -8,6 +9,7 @@ import 'src/domain/repositories/hub_repository_interface.dart'; import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/create_hub_usecase.dart'; import 'src/domain/usecases/delete_hub_usecase.dart'; +import 'src/domain/usecases/get_cost_centers_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart'; import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart'; @@ -32,6 +34,7 @@ class ClientHubsModule extends Module { // UseCases i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(GetCostCentersUseCase.new); i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new); @@ -61,6 +64,18 @@ class ClientHubsModule extends Module { ); r.child( ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub), + transition: TransitionType.custom, + customTransition: CustomTransition( + opaque: false, + transitionBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition(opacity: animation, child: child); + }, + ), child: (_) { final Map data = r.args.data as Map; return EditHubPage( diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 3e15fa71..ac91ac28 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -1,4 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/hub_repository_interface.dart'; @@ -24,6 +24,24 @@ class HubRepositoryImpl implements HubRepositoryInterface { return _connectorRepository.getHubs(businessId: businessId); } + @override + Future> getCostCenters() async { + return _service.run(() async { + final result = await _service.connector.listTeamHudDepartments().execute(); + final Set seen = {}; + final List costCenters = []; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in result.data.teamHudDepartments) { + final String? cc = dep.costCenter; + if (cc != null && cc.isNotEmpty && !seen.contains(cc)) { + seen.add(cc); + costCenters.add(CostCenter(id: cc, name: dep.name, code: cc)); + } + } + return costCenters; + }); + } + @override Future createHub({ required String name, @@ -36,6 +54,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }) async { final String businessId = await _service.getBusinessId(); return _connectorRepository.createHub( @@ -50,6 +69,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { street: street, country: country, zipCode: zipCode, + costCenterId: costCenterId, ); } @@ -79,6 +99,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }) async { final String businessId = await _service.getBusinessId(); return _connectorRepository.updateHub( @@ -94,6 +115,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { street: street, country: country, zipCode: zipCode, + costCenterId: costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart index ad6199de..18e6a3fd 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart @@ -19,6 +19,7 @@ class CreateHubArguments extends UseCaseArgument { this.street, this.country, this.zipCode, + this.costCenterId, }); /// The name of the hub. final String name; @@ -34,6 +35,9 @@ class CreateHubArguments extends UseCaseArgument { final String? street; final String? country; final String? zipCode; + + /// The cost center of the hub. + final String? costCenterId; @override List get props => [ @@ -47,5 +51,6 @@ class CreateHubArguments extends UseCaseArgument { street, country, zipCode, + costCenterId, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 0288d180..14e97bf2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface { /// Returns a list of [Hub] entities. Future> getHubs(); + /// Fetches the list of available cost centers for the current business. + Future> getCostCenters(); + /// Creates a new hub. /// /// Takes the [name] and [address] of the new hub. @@ -26,6 +29,7 @@ abstract interface class HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Deletes a hub by its [id]. @@ -51,5 +55,6 @@ abstract interface class HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart index 9c55ed30..550acd89 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart @@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase { street: arguments.street, country: arguments.country, zipCode: arguments.zipCode, + costCenterId: arguments.costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart new file mode 100644 index 00000000..32f9d895 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart @@ -0,0 +1,14 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/hub_repository_interface.dart'; + +/// Usecase to fetch all available cost centers. +class GetCostCentersUseCase { + GetCostCentersUseCase({required HubRepositoryInterface repository}) + : _repository = repository; + + final HubRepositoryInterface _repository; + + Future> call() async { + return _repository.getCostCenters(); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart index 97af203e..cbfdb799 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -17,6 +17,7 @@ class UpdateHubArguments extends UseCaseArgument { this.street, this.country, this.zipCode, + this.costCenterId, }); final String id; @@ -30,6 +31,7 @@ class UpdateHubArguments extends UseCaseArgument { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -44,6 +46,7 @@ class UpdateHubArguments extends UseCaseArgument { street, country, zipCode, + costCenterId, ]; } @@ -67,6 +70,7 @@ class UpdateHubUseCase implements UseCase { street: params.street, country: params.country, zipCode: params.zipCode, + costCenterId: params.costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart index 6923899a..a455c0f3 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart @@ -1,8 +1,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../../domain/arguments/create_hub_arguments.dart'; import '../../../domain/usecases/create_hub_usecase.dart'; import '../../../domain/usecases/update_hub_usecase.dart'; +import '../../../domain/usecases/get_cost_centers_usecase.dart'; import 'edit_hub_event.dart'; import 'edit_hub_state.dart'; @@ -12,15 +14,36 @@ class EditHubBloc extends Bloc EditHubBloc({ required CreateHubUseCase createHubUseCase, required UpdateHubUseCase updateHubUseCase, + required GetCostCentersUseCase getCostCentersUseCase, }) : _createHubUseCase = createHubUseCase, _updateHubUseCase = updateHubUseCase, + _getCostCentersUseCase = getCostCentersUseCase, super(const EditHubState()) { + on(_onCostCentersLoadRequested); on(_onAddRequested); on(_onUpdateRequested); } final CreateHubUseCase _createHubUseCase; final UpdateHubUseCase _updateHubUseCase; + final GetCostCentersUseCase _getCostCentersUseCase; + + Future _onCostCentersLoadRequested( + EditHubCostCentersLoadRequested event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + final List costCenters = await _getCostCentersUseCase.call(); + emit(state.copyWith(costCenters: costCenters)); + }, + onError: (String errorKey) => state.copyWith( + status: EditHubStatus.failure, + errorMessage: errorKey, + ), + ); + } Future _onAddRequested( EditHubAddRequested event, @@ -43,12 +66,13 @@ class EditHubBloc extends Bloc street: event.street, country: event.country, zipCode: event.zipCode, + costCenterId: event.costCenterId, ), ); emit( state.copyWith( status: EditHubStatus.success, - successMessage: 'Hub created successfully', + successKey: 'created', ), ); }, @@ -79,12 +103,13 @@ class EditHubBloc extends Bloc street: event.street, country: event.country, zipCode: event.zipCode, + costCenterId: event.costCenterId, ), ); emit( state.copyWith( status: EditHubStatus.success, - successMessage: 'Hub updated successfully', + successKey: 'updated', ), ); }, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart index 65e18a83..38e25de0 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart @@ -8,6 +8,11 @@ abstract class EditHubEvent extends Equatable { List get props => []; } +/// Event triggered to load all available cost centers. +class EditHubCostCentersLoadRequested extends EditHubEvent { + const EditHubCostCentersLoadRequested(); +} + /// Event triggered to add a new hub. class EditHubAddRequested extends EditHubEvent { const EditHubAddRequested({ @@ -21,6 +26,7 @@ class EditHubAddRequested extends EditHubEvent { this.street, this.country, this.zipCode, + this.costCenterId, }); final String name; @@ -33,6 +39,7 @@ class EditHubAddRequested extends EditHubEvent { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -46,6 +53,7 @@ class EditHubAddRequested extends EditHubEvent { street, country, zipCode, + costCenterId, ]; } @@ -63,6 +71,7 @@ class EditHubUpdateRequested extends EditHubEvent { this.street, this.country, this.zipCode, + this.costCenterId, }); final String id; @@ -76,6 +85,7 @@ class EditHubUpdateRequested extends EditHubEvent { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -90,5 +100,6 @@ class EditHubUpdateRequested extends EditHubEvent { street, country, zipCode, + costCenterId, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart index 17bdffcd..2c59b055 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Status of the edit hub operation. enum EditHubStatus { @@ -21,6 +22,8 @@ class EditHubState extends Equatable { this.status = EditHubStatus.initial, this.errorMessage, this.successMessage, + this.successKey, + this.costCenters = const [], }); /// The status of the operation. @@ -32,19 +35,35 @@ class EditHubState extends Equatable { /// The success message if the operation succeeded. final String? successMessage; + /// Localization key for success message: 'created' | 'updated'. + final String? successKey; + + /// Available cost centers for selection. + final List costCenters; + /// Create a copy of this state with the given fields replaced. EditHubState copyWith({ EditHubStatus? status, String? errorMessage, String? successMessage, + String? successKey, + List? costCenters, }) { return EditHubState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, + costCenters: costCenters ?? this.costCenters, ); } @override - List get props => [status, errorMessage, successMessage]; + List get props => [ + status, + errorMessage, + successMessage, + successKey, + costCenters, + ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart index bda30551..4b91b0de 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart @@ -36,7 +36,7 @@ class HubDetailsBloc extends Bloc emit( state.copyWith( status: HubDetailsStatus.deleted, - successMessage: 'Hub deleted successfully', + successKey: 'deleted', ), ); }, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart index f2c7f4c2..17ef70f8 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart @@ -24,6 +24,7 @@ class HubDetailsState extends Equatable { this.status = HubDetailsStatus.initial, this.errorMessage, this.successMessage, + this.successKey, }); /// The status of the operation. @@ -35,19 +36,24 @@ class HubDetailsState extends Equatable { /// The success message if the operation succeeded. final String? successMessage; + /// Localization key for success message: 'deleted'. + final String? successKey; + /// Create a copy of this state with the given fields replaced. HubDetailsState copyWith({ HubDetailsStatus? status, String? errorMessage, String? successMessage, + String? successKey, }) { return HubDetailsState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, ); } @override - List get props => [status, errorMessage, successMessage]; + List get props => [status, errorMessage, successMessage, successKey]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index 1bcdb4ed..25772bc2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -57,20 +57,6 @@ class ClientHubsPage extends StatelessWidget { builder: (BuildContext context, ClientHubsState state) { return Scaffold( backgroundColor: UiColors.bgMenu, - floatingActionButton: FloatingActionButton( - onPressed: () async { - final bool? success = await Modular.to.toEditHub(); - if (success == true && context.mounted) { - BlocProvider.of( - context, - ).add(const ClientHubsFetched()); - } - }, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - child: const Icon(UiIcons.add), - ), body: CustomScrollView( slivers: [ _buildAppBar(context), @@ -165,20 +151,35 @@ class ClientHubsPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_hubs.title, - style: UiTypography.headline1m.white, - ), - Text( - t.client_hubs.subtitle, - style: UiTypography.body2r.copyWith( - color: UiColors.switchInactive, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.title, + style: UiTypography.headline1m.white, ), - ), - ], + Text( + t.client_hubs.subtitle, + style: UiTypography.body2r.copyWith( + color: UiColors.switchInactive, + ), + ), + ], + ), + ), + UiButton.primary( + onPressed: () async { + final bool? success = await Modular.to.toEditHub(); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + text: t.client_hubs.add_hub, + leadingIcon: UiIcons.add, + size: UiButtonSize.small, ), ], ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart index ea547ab2..8bc8373e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -3,15 +3,14 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/edit_hub/edit_hub_bloc.dart'; import '../blocs/edit_hub/edit_hub_event.dart'; import '../blocs/edit_hub/edit_hub_state.dart'; -import '../widgets/edit_hub/edit_hub_form_section.dart'; +import '../widgets/hub_form_dialog.dart'; -/// A dedicated full-screen page for adding or editing a hub. +/// A wrapper page that shows the hub form in a modal-style layout. class EditHubPage extends StatefulWidget { const EditHubPage({this.hub, required this.bloc, super.key}); @@ -23,66 +22,11 @@ class EditHubPage extends StatefulWidget { } class _EditHubPageState extends State { - final GlobalKey _formKey = GlobalKey(); - late final TextEditingController _nameController; - late final TextEditingController _addressController; - late final FocusNode _addressFocusNode; - Prediction? _selectedPrediction; - @override void initState() { super.initState(); - _nameController = TextEditingController(text: widget.hub?.name); - _addressController = TextEditingController(text: widget.hub?.address); - _addressFocusNode = FocusNode(); - - // Update header on change (if header is added back) - _nameController.addListener(() => setState(() {})); - _addressController.addListener(() => setState(() {})); - } - - @override - void dispose() { - _nameController.dispose(); - _addressController.dispose(); - _addressFocusNode.dispose(); - super.dispose(); - } - - void _onSave() { - if (!_formKey.currentState!.validate()) return; - - if (_addressController.text.trim().isEmpty) { - UiSnackbar.show( - context, - message: t.client_hubs.add_hub_dialog.address_hint, - type: UiSnackbarType.error, - ); - return; - } - - if (widget.hub == null) { - widget.bloc.add( - EditHubAddRequested( - name: _nameController.text.trim(), - address: _addressController.text.trim(), - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse(_selectedPrediction?.lat ?? ''), - longitude: double.tryParse(_selectedPrediction?.lng ?? ''), - ), - ); - } else { - widget.bloc.add( - EditHubUpdateRequested( - id: widget.hub!.id, - name: _nameController.text.trim(), - address: _addressController.text.trim(), - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse(_selectedPrediction?.lat ?? ''), - longitude: double.tryParse(_selectedPrediction?.lng ?? ''), - ), - ); - } + // Load available cost centers + widget.bloc.add(const EditHubCostCentersLoadRequested()); } @override @@ -91,17 +35,18 @@ class _EditHubPageState extends State { value: widget.bloc, child: BlocListener( listenWhen: (EditHubState prev, EditHubState curr) => - prev.status != curr.status || - prev.successMessage != curr.successMessage, + prev.status != curr.status || prev.successKey != curr.successKey, listener: (BuildContext context, EditHubState state) { if (state.status == EditHubStatus.success && - state.successMessage != null) { + state.successKey != null) { + final String message = state.successKey == 'created' + ? t.client_hubs.edit_hub.created_success + : t.client_hubs.edit_hub.updated_success; UiSnackbar.show( context, - message: state.successMessage!, + message: message, type: UiSnackbarType.success, ); - // Pop back to the previous screen. Modular.to.pop(true); } if (state.status == EditHubStatus.failure && @@ -118,42 +63,59 @@ class _EditHubPageState extends State { final bool isSaving = state.status == EditHubStatus.loading; return Scaffold( - backgroundColor: UiColors.bgMenu, - appBar: UiAppBar( - title: widget.hub == null - ? t.client_hubs.add_hub_dialog.title - : t.client_hubs.edit_hub.title, - subtitle: widget.hub == null - ? t.client_hubs.add_hub_dialog.create_button - : t.client_hubs.edit_hub.subtitle, - onLeadingPressed: () => Modular.to.pop(), - ), + backgroundColor: UiColors.bgOverlay, body: Stack( children: [ - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: EditHubFormSection( - formKey: _formKey, - nameController: _nameController, - addressController: _addressController, - addressFocusNode: _addressFocusNode, - onAddressSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, - onSave: _onSave, - isSaving: isSaving, - isEdit: widget.hub != null, - ), - ), - ], + // Tap background to dismiss + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container(color: Colors.transparent), + ), + + // Dialog-style content centered + Align( + alignment: Alignment.center, + child: HubFormDialog( + hub: widget.hub, + costCenters: state.costCenters, + onCancel: () => Modular.to.pop(), + onSave: ({ + required String name, + required String address, + String? costCenterId, + String? placeId, + double? latitude, + double? longitude, + }) { + if (widget.hub == null) { + widget.bloc.add( + EditHubAddRequested( + name: name, + address: address, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } else { + widget.bloc.add( + EditHubUpdateRequested( + id: widget.hub!.id, + name: name, + address: address, + costCenterId: costCenterId, + placeId: placeId, + latitude: latitude, + longitude: longitude, + ), + ); + } + }, ), ), - // ── Loading overlay ────────────────────────────────────── + // Global loading overlay if saving if (isSaving) Container( color: UiColors.black.withValues(alpha: 0.1), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index cbcf5d61..16861eb5 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -29,9 +29,12 @@ class HubDetailsPage extends StatelessWidget { child: BlocListener( listener: (BuildContext context, HubDetailsState state) { if (state.status == HubDetailsStatus.deleted) { + final String message = state.successKey == 'deleted' + ? t.client_hubs.hub_details.deleted_success + : (state.successMessage ?? t.client_hubs.hub_details.deleted_success); UiSnackbar.show( context, - message: state.successMessage ?? 'Hub deleted successfully', + message: message, type: UiSnackbarType.success, ); Modular.to.pop(true); // Return true to indicate change @@ -80,6 +83,15 @@ class HubDetailsPage extends StatelessWidget { icon: UiIcons.nfc, isHighlight: hub.nfcTagId != null, ), + const SizedBox(height: UiConstants.space4), + HubDetailsItem( + label: t.client_hubs.hub_details.cost_center_label, + value: hub.costCenter != null + ? '${hub.costCenter!.name} (${hub.costCenter!.code})' + : t.client_hubs.hub_details.cost_center_none, + icon: UiIcons.bank, // Using bank icon for cost center + isHighlight: hub.costCenter != null, + ), ], ), ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart index b874dd3b..3a6e24f6 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart @@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../hub_address_autocomplete.dart'; import 'edit_hub_field_label.dart'; @@ -15,6 +16,9 @@ class EditHubFormSection extends StatelessWidget { required this.addressFocusNode, required this.onAddressSelected, required this.onSave, + this.costCenters = const [], + this.selectedCostCenterId, + required this.onCostCenterChanged, this.isSaving = false, this.isEdit = false, super.key, @@ -26,6 +30,9 @@ class EditHubFormSection extends StatelessWidget { final FocusNode addressFocusNode; final ValueChanged onAddressSelected; final VoidCallback onSave; + final List costCenters; + final String? selectedCostCenterId; + final ValueChanged onCostCenterChanged; final bool isSaving; final bool isEdit; @@ -44,7 +51,7 @@ class EditHubFormSection extends StatelessWidget { textInputAction: TextInputAction.next, validator: (String? value) { if (value == null || value.trim().isEmpty) { - return 'Name is required'; + return t.client_hubs.edit_hub.name_required; } return null; }, @@ -62,6 +69,51 @@ class EditHubFormSection extends StatelessWidget { onSelected: onAddressSelected, ), + const SizedBox(height: UiConstants.space4), + + EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label), + InkWell( + onTap: () => _showCostCenterSelector(context), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.input, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: selectedCostCenterId != null + ? UiColors.ring + : UiColors.border, + width: selectedCostCenterId != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedCostCenterId != null + ? _getCostCenterName(selectedCostCenterId!) + : t.client_hubs.edit_hub.cost_center_hint, + style: selectedCostCenterId != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + const SizedBox(height: UiConstants.space8), // ── Save button ────────────────────────────────── @@ -102,4 +154,59 @@ class EditHubFormSection extends StatelessWidget { ), ); } + + String _getCostCenterName(String id) { + try { + final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id); + return cc.code != null ? '${cc.name} (${cc.code})' : cc.name; + } catch (_) { + return id; + } + } + + Future _showCostCenterSelector(BuildContext context) async { + final CostCenter? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_hubs.edit_hub.cost_center_label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child : costCenters.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.edit_hub.cost_centers_empty), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: costCenters.length, + itemBuilder: (BuildContext context, int index) { + final CostCenter cc = costCenters[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(cc.name, style: UiTypography.body1m.textPrimary), + subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null, + onTap: () => Navigator.of(context).pop(cc), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + onCostCenterChanged(selected.id); + } + } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index 66f14d11..ee196446 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget { required this.controller, required this.hintText, this.focusNode, + this.decoration, this.onSelected, super.key, }); @@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget { final TextEditingController controller; final String hintText; final FocusNode? focusNode; + final InputDecoration? decoration; final void Function(Prediction prediction)? onSelected; @override @@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget { return GooglePlaceAutoCompleteTextField( textEditingController: controller, focusNode: focusNode, + inputDecoration: decoration ?? const InputDecoration(), googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, countries: HubsConstants.supportedCountries, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart index 7a4d0cd7..25d5f4b0 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart @@ -5,25 +5,30 @@ import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; import 'hub_address_autocomplete.dart'; +import 'edit_hub/edit_hub_field_label.dart'; -/// A dialog for adding or editing a hub. +/// A bottom sheet dialog for adding or editing a hub. class HubFormDialog extends StatefulWidget { - /// Creates a [HubFormDialog]. const HubFormDialog({ required this.onSave, required this.onCancel, this.hub, + this.costCenters = const [], super.key, }); /// The hub to edit. If null, a new hub is created. final Hub? hub; + /// Available cost centers for selection. + final List costCenters; + /// Callback when the "Save" button is pressed. - final void Function( - String name, - String address, { + final void Function({ + required String name, + required String address, + String? costCenterId, String? placeId, double? latitude, double? longitude, @@ -40,6 +45,7 @@ class _HubFormDialogState extends State { late final TextEditingController _nameController; late final TextEditingController _addressController; late final FocusNode _addressFocusNode; + String? _selectedCostCenterId; Prediction? _selectedPrediction; @override @@ -48,6 +54,7 @@ class _HubFormDialogState extends State { _nameController = TextEditingController(text: widget.hub?.name); _addressController = TextEditingController(text: widget.hub?.address); _addressFocusNode = FocusNode(); + _selectedCostCenterId = widget.hub?.costCenter?.id; } @override @@ -63,102 +70,193 @@ class _HubFormDialogState extends State { @override Widget build(BuildContext context) { final bool isEditing = widget.hub != null; - final String title = isEditing - ? 'Edit Hub' // TODO: localize + final String title = isEditing + ? t.client_hubs.edit_hub.title : t.client_hubs.add_hub_dialog.title; - + final String buttonText = isEditing - ? 'Save Changes' // TODO: localize + ? t.client_hubs.edit_hub.save_button : t.client_hubs.add_hub_dialog.create_button; - return Container( - color: UiColors.bgOverlay, - child: Center( - child: SingleChildScrollView( - child: Container( - width: MediaQuery.of(context).size.width * 0.9, - padding: const EdgeInsets.all(UiConstants.space5), - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow(color: UiColors.popupShadow, blurRadius: 20), - ], + return Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.15), + blurRadius: 30, + offset: const Offset(0, 10), ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: UiTypography.headline3m.textPrimary, + ], + ), + padding: const EdgeInsets.all(UiConstants.space6), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: UiTypography.headline3m.textPrimary.copyWith( + fontSize: 20, ), - const SizedBox(height: UiConstants.space5), - _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), - TextFormField( - controller: _nameController, - style: UiTypography.body1r.textPrimary, - validator: (String? value) { - if (value == null || value.trim().isEmpty) { - return 'Name is required'; - } - return null; - }, - decoration: _buildInputDecoration( - t.client_hubs.add_hub_dialog.name_hint, + ), + const SizedBox(height: UiConstants.space5), + + // ── Hub Name ──────────────────────────────── + EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label), + const SizedBox(height: UiConstants.space2), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return t.client_hubs.add_hub_dialog.name_required; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Cost Center ───────────────────────────── + EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label), + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: _showCostCenterSelector, + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 16, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFD), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + border: Border.all( + color: _selectedCostCenterId != null + ? UiColors.primary + : UiColors.primary.withValues(alpha: 0.1), + width: _selectedCostCenterId != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _selectedCostCenterId != null + ? _getCostCenterName(_selectedCostCenterId!) + : t.client_hubs.add_hub_dialog.cost_center_hint, + style: _selectedCostCenterId != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder.copyWith( + color: UiColors.textSecondary.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], ), ), - const SizedBox(height: UiConstants.space4), - _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.add_hub_dialog.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, + ), + + const SizedBox(height: UiConstants.space4), + + // ── Address ───────────────────────────────── + EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label), + const SizedBox(height: UiConstants.space2), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.address_hint, ), - const SizedBox(height: UiConstants.space8), - Row( - children: [ - Expanded( - child: UiButton.secondary( - onPressed: widget.onCancel, - text: t.common.cancel, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + + const SizedBox(height: UiConstants.space8), + + // ── Buttons ───────────────────────────────── + Row( + children: [ + Expanded( + child: UiButton.secondary( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: UiColors.primary.withValues(alpha: 0.1), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase * 1.5, + ), + ), ), + onPressed: widget.onCancel, + text: t.common.cancel, ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: UiButton.primary( - onPressed: () { - if (_formKey.currentState!.validate()) { - if (_addressController.text.trim().isEmpty) { - UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); - return; - } - - widget.onSave( - _nameController.text, - _addressController.text, - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse( - _selectedPrediction?.lat ?? '', - ), - longitude: double.tryParse( - _selectedPrediction?.lng ?? '', - ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase * 1.5, + ), + ), + ), + onPressed: () { + if (_formKey.currentState!.validate()) { + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show( + context, + message: t.client_hubs.add_hub_dialog.address_required, + type: UiSnackbarType.error, ); + return; } - }, - text: buttonText, - ), + + widget.onSave( + name: _nameController.text.trim(), + address: _addressController.text.trim(), + costCenterId: _selectedCostCenterId, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: buttonText, ), - ], - ), - ], - ), + ), + ], + ), + ], ), ), ), @@ -166,35 +264,87 @@ class _HubFormDialogState extends State { ); } - Widget _buildFieldLabel(String label) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Text(label, style: UiTypography.body2m.textPrimary), - ); - } - InputDecoration _buildInputDecoration(String hint) { return InputDecoration( hintText: hint, - hintStyle: UiTypography.body2r.textPlaceholder, + hintStyle: UiTypography.body2r.textPlaceholder.copyWith( + color: UiColors.textSecondary.withValues(alpha: 0.5), + ), filled: true, - fillColor: UiColors.input, + fillColor: const Color(0xFFF8FAFD), contentPadding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, - vertical: 14, + vertical: 16, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.ring, width: 2), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: const BorderSide(color: UiColors.primary, width: 2), ), + errorStyle: UiTypography.footnote2r.textError, ); } + + String _getCostCenterName(String id) { + try { + return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name; + } catch (_) { + return id; + } + } + + Future _showCostCenterSelector() async { + final CostCenter? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_hubs.add_hub_dialog.cost_center_label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: widget.costCenters.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.add_hub_dialog.cost_centers_empty), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: widget.costCenters.length, + itemBuilder: (BuildContext context, int index) { + final CostCenter cc = widget.costCenters[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(cc.name, style: UiTypography.body1m.textPrimary), + subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null, + onTap: () => Navigator.of(context).pop(cc), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + setState(() { + _selectedCostCenterId = selected.id; + }); + } + } } diff --git a/apps/mobile/packages/features/client/orders/analyze.txt b/apps/mobile/packages/features/client/orders/analyze.txt new file mode 100644 index 00000000..28d6d1d5 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze.txt differ diff --git a/apps/mobile/packages/features/client/orders/analyze_output.txt b/apps/mobile/packages/features/client/orders/analyze_output.txt new file mode 100644 index 00000000..53f8069c Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze_output.txt differ diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 9b9891b4..a255fe7d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -31,6 +31,8 @@ class OneTimeOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -134,6 +136,43 @@ class OneTimeOrderBloc extends Bloc } } + Future _loadManagersForHub( + String hubId, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + OneTimeOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) { + add(const OneTimeOrderManagersLoaded([])); + }, + ); + + if (managers != null) { + add(OneTimeOrderManagersLoaded(managers)); + } + } + + Future _onVendorsLoaded( OneTimeOrderVendorsLoaded event, Emitter emit, @@ -171,15 +210,36 @@ class OneTimeOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id); + } } + void _onHubChanged( OneTimeOrderHubChanged event, Emitter emit, ) { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id); } + void _onHubManagerChanged( + OneTimeOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + OneTimeOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + void _onEventNameChanged( OneTimeOrderEventNameChanged event, Emitter emit, @@ -267,6 +327,7 @@ class OneTimeOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart index b6255dab..b64f0542 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart @@ -89,3 +89,21 @@ class OneTimeOrderInitialized extends OneTimeOrderEvent { @override List get props => [data]; } + +class OneTimeOrderHubManagerChanged extends OneTimeOrderEvent { + const OneTimeOrderHubManagerChanged(this.manager); + final OneTimeOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class OneTimeOrderManagersLoaded extends OneTimeOrderEvent { + const OneTimeOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index d21bbfc3..b48b9134 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -16,6 +16,8 @@ class OneTimeOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory OneTimeOrderState.initial() { @@ -29,6 +31,7 @@ class OneTimeOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } final DateTime date; @@ -42,6 +45,8 @@ class OneTimeOrderState extends Equatable { final List hubs; final OneTimeOrderHubOption? selectedHub; final List roles; + final List managers; + final OneTimeOrderManagerOption? selectedManager; OneTimeOrderState copyWith({ DateTime? date, @@ -55,6 +60,8 @@ class OneTimeOrderState extends Equatable { List? hubs, OneTimeOrderHubOption? selectedHub, List? roles, + List? managers, + OneTimeOrderManagerOption? selectedManager, }) { return OneTimeOrderState( date: date ?? this.date, @@ -68,6 +75,8 @@ class OneTimeOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -98,6 +107,8 @@ class OneTimeOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -158,3 +169,17 @@ class OneTimeOrderRoleOption extends Equatable { @override List get props => [id, name, costPerHour]; } + +class OneTimeOrderManagerOption extends Equatable { + const OneTimeOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 6f173604..5c0c34af 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -31,6 +31,8 @@ class PermanentOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -182,6 +184,10 @@ class PermanentOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id, emit); + } } void _onHubChanged( @@ -189,8 +195,61 @@ class PermanentOrderBloc extends Bloc Emitter emit, ) { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id, emit); } + void _onHubManagerChanged( + PermanentOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + PermanentOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + PermanentOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } + } + + void _onEventNameChanged( PermanentOrderEventNameChanged event, Emitter emit, @@ -330,6 +389,7 @@ class PermanentOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createPermanentOrderUseCase(order); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart index 28dcbcd3..f194618c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart @@ -106,3 +106,20 @@ class PermanentOrderInitialized extends PermanentOrderEvent { @override List get props => [data]; } + +class PermanentOrderHubManagerChanged extends PermanentOrderEvent { + const PermanentOrderHubManagerChanged(this.manager); + final PermanentOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class PermanentOrderManagersLoaded extends PermanentOrderEvent { + const PermanentOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index 38dc743e..4cd04e66 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -18,6 +18,8 @@ class PermanentOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory PermanentOrderState.initial() { @@ -45,6 +47,7 @@ class PermanentOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } @@ -61,6 +64,8 @@ class PermanentOrderState extends Equatable { final List hubs; final PermanentOrderHubOption? selectedHub; final List roles; + final List managers; + final PermanentOrderManagerOption? selectedManager; PermanentOrderState copyWith({ DateTime? startDate, @@ -76,6 +81,8 @@ class PermanentOrderState extends Equatable { List? hubs, PermanentOrderHubOption? selectedHub, List? roles, + List? managers, + PermanentOrderManagerOption? selectedManager, }) { return PermanentOrderState( startDate: startDate ?? this.startDate, @@ -91,6 +98,8 @@ class PermanentOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -124,6 +133,8 @@ class PermanentOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -185,6 +196,20 @@ class PermanentOrderRoleOption extends Equatable { List get props => [id, name, costPerHour]; } +class PermanentOrderManagerOption extends Equatable { + const PermanentOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + class PermanentOrderPosition extends Equatable { const PermanentOrderPosition({ required this.role, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 0673531e..2c51fef9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -32,6 +32,8 @@ class RecurringOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -169,10 +171,10 @@ class RecurringOrderBloc extends Bloc await _loadRolesForVendor(event.vendor.id, emit); } - void _onHubsLoaded( + Future _onHubsLoaded( RecurringOrderHubsLoaded event, Emitter emit, - ) { + ) async { final RecurringOrderHubOption? selectedHub = event.hubs.isNotEmpty ? event.hubs.first : null; @@ -183,13 +185,69 @@ class RecurringOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + await _loadManagersForHub(selectedHub.id, emit); + } } - void _onHubChanged( + Future _onHubChanged( RecurringOrderHubChanged event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + await _loadManagersForHub(event.hub.id, emit); + } + + void _onHubManagerChanged( + RecurringOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + RecurringOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + RecurringOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } } void _onEventNameChanged( @@ -349,6 +407,7 @@ class RecurringOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createRecurringOrderUseCase(order); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart index a04dbdbb..779e97cf 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart @@ -115,3 +115,20 @@ class RecurringOrderInitialized extends RecurringOrderEvent { @override List get props => [data]; } + +class RecurringOrderHubManagerChanged extends RecurringOrderEvent { + const RecurringOrderHubManagerChanged(this.manager); + final RecurringOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class RecurringOrderManagersLoaded extends RecurringOrderEvent { + const RecurringOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 626beae8..8a22eb64 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -19,6 +19,8 @@ class RecurringOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory RecurringOrderState.initial() { @@ -47,6 +49,7 @@ class RecurringOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } @@ -64,6 +67,8 @@ class RecurringOrderState extends Equatable { final List hubs; final RecurringOrderHubOption? selectedHub; final List roles; + final List managers; + final RecurringOrderManagerOption? selectedManager; RecurringOrderState copyWith({ DateTime? startDate, @@ -80,6 +85,8 @@ class RecurringOrderState extends Equatable { List? hubs, RecurringOrderHubOption? selectedHub, List? roles, + List? managers, + RecurringOrderManagerOption? selectedManager, }) { return RecurringOrderState( startDate: startDate ?? this.startDate, @@ -96,6 +103,8 @@ class RecurringOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -132,6 +141,8 @@ class RecurringOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -193,6 +204,20 @@ class RecurringOrderRoleOption extends Equatable { List get props => [id, name, costPerHour]; } +class RecurringOrderManagerOption extends Equatable { + const RecurringOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + class RecurringOrderPosition extends Equatable { const RecurringOrderPosition({ required this.role, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 899e787b..8c8f0e3f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -48,6 +48,10 @@ class OneTimeOrderPage extends StatelessWidget { hubs: state.hubs.map(_mapHub).toList(), positions: state.positions.map(_mapPosition).toList(), roles: state.roles.map(_mapRole).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, + hubManagers: state.managers.map(_mapManager).toList(), isValid: state.isValid, onEventNameChanged: (String val) => bloc.add(OneTimeOrderEventNameChanged(val)), @@ -61,6 +65,17 @@ class OneTimeOrderPage extends StatelessWidget { ); bloc.add(OneTimeOrderHubChanged(originalHub)); }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const OneTimeOrderHubManagerChanged(null)); + return; + } + final OneTimeOrderManagerOption original = + state.managers.firstWhere( + (OneTimeOrderManagerOption m) => m.id == val.id, + ); + bloc.add(OneTimeOrderHubManagerChanged(original)); + }, onPositionAdded: () => bloc.add(const OneTimeOrderPositionAdded()), onPositionUpdated: (int index, OrderPositionUiModel val) { final OneTimeOrderPosition original = state.positions[index]; @@ -130,4 +145,9 @@ class OneTimeOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak, ); } + + OrderManagerUiModel _mapManager(OneTimeOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 2fb67a03..26109e7a 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -42,6 +42,10 @@ class PermanentOrderPage extends StatelessWidget { ? _mapHub(state.selectedHub!) : null, hubs: state.hubs.map(_mapHub).toList(), + hubManagers: state.managers.map(_mapManager).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, positions: state.positions.map(_mapPosition).toList(), roles: state.roles.map(_mapRole).toList(), isValid: state.isValid, @@ -59,6 +63,17 @@ class PermanentOrderPage extends StatelessWidget { ); bloc.add(PermanentOrderHubChanged(originalHub)); }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const PermanentOrderHubManagerChanged(null)); + return; + } + final PermanentOrderManagerOption original = + state.managers.firstWhere( + (PermanentOrderManagerOption m) => m.id == val.id, + ); + bloc.add(PermanentOrderHubManagerChanged(original)); + }, onPositionAdded: () => bloc.add(const PermanentOrderPositionAdded()), onPositionUpdated: (int index, OrderPositionUiModel val) { @@ -181,4 +196,8 @@ class PermanentOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak ?? 'NO_BREAK', ); } + + OrderManagerUiModel _mapManager(PermanentOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index 6954e826..c65c26a3 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -43,6 +43,10 @@ class RecurringOrderPage extends StatelessWidget { ? _mapHub(state.selectedHub!) : null, hubs: state.hubs.map(_mapHub).toList(), + hubManagers: state.managers.map(_mapManager).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, positions: state.positions.map(_mapPosition).toList(), roles: state.roles.map(_mapRole).toList(), isValid: state.isValid, @@ -62,6 +66,17 @@ class RecurringOrderPage extends StatelessWidget { ); bloc.add(RecurringOrderHubChanged(originalHub)); }, + onHubManagerChanged: (OrderManagerUiModel? val) { + if (val == null) { + bloc.add(const RecurringOrderHubManagerChanged(null)); + return; + } + final RecurringOrderManagerOption original = + state.managers.firstWhere( + (RecurringOrderManagerOption m) => m.id == val.id, + ); + bloc.add(RecurringOrderHubManagerChanged(original)); + }, onPositionAdded: () => bloc.add(const RecurringOrderPositionAdded()), onPositionUpdated: (int index, OrderPositionUiModel val) { @@ -193,4 +208,8 @@ class RecurringOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak ?? 'NO_BREAK', ); } + + OrderManagerUiModel _mapManager(RecurringOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart new file mode 100644 index 00000000..185b9bef --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart @@ -0,0 +1,167 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'order_ui_models.dart'; + +class HubManagerSelector extends StatelessWidget { + const HubManagerSelector({ + required this.managers, + required this.selectedManager, + required this.onChanged, + required this.hintText, + required this.label, + this.description, + this.noManagersText, + this.noneText, + super.key, + }); + + final List managers; + final OrderManagerUiModel? selectedManager; + final ValueChanged onChanged; + final String hintText; + final String label; + final String? description; + final String? noManagersText; + final String? noneText; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + style: UiTypography.body1m.textPrimary, + ), + if (description != null) ...[ + const SizedBox(height: UiConstants.space2), + Text(description!, style: UiTypography.body2r.textSecondary), + ], + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: () => _showSelector(context), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: selectedManager != null ? UiColors.primary : UiColors.border, + width: selectedManager != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + UiIcons.user, + color: selectedManager != null + ? UiColors.primary + : UiColors.iconSecondary, + size: 20, + ), + const SizedBox(width: UiConstants.space3), + Text( + selectedManager?.name ?? hintText, + style: selectedManager != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + Future _showSelector(BuildContext context) async { + final OrderManagerUiModel? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + label, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + shrinkWrap: true, + itemCount: managers.isEmpty ? 2 : managers.length + 1, + itemBuilder: (BuildContext context, int index) { + final String emptyText = noManagersText ?? 'No hub managers available'; + final String noneLabel = noneText ?? 'None'; + if (managers.isEmpty) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(emptyText), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + if (index == managers.length) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + final OrderManagerUiModel manager = managers[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + title: Text(manager.name, style: UiTypography.body1m.textPrimary), + subtitle: manager.phone != null + ? Text(manager.phone!, style: UiTypography.body2r.textSecondary) + : null, + onTap: () => Navigator.of(context).pop(manager), + ); + }, + ), + ), + ), + ); + }, + ); + + if (selected != null) { + if (selected.id == 'NONE') { + onChanged(null); + } else { + onChanged(selected); + } + } + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index ba891dcc..4abe0eae 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'one_time_order_date_picker.dart'; import 'one_time_order_event_name_input.dart'; import 'one_time_order_header.dart'; @@ -23,11 +24,14 @@ class OneTimeOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, required this.onDateChanged, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -47,12 +51,15 @@ class OneTimeOrderView extends StatelessWidget { final List hubs; final List positions; final List roles; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; final bool isValid; final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; final ValueChanged onDateChanged; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -143,12 +150,15 @@ class OneTimeOrderView extends StatelessWidget { date: date, selectedHub: selectedHub, hubs: hubs, + selectedHubManager: selectedHubManager, + hubManagers: hubManagers, positions: positions, roles: roles, onEventNameChanged: onEventNameChanged, onVendorChanged: onVendorChanged, onDateChanged: onDateChanged, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, @@ -179,12 +189,15 @@ class _OneTimeOrderForm extends StatelessWidget { required this.date, required this.selectedHub, required this.hubs, + required this.selectedHubManager, + required this.hubManagers, required this.positions, required this.roles, required this.onEventNameChanged, required this.onVendorChanged, required this.onDateChanged, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -196,6 +209,8 @@ class _OneTimeOrderForm extends StatelessWidget { final DateTime date; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; @@ -203,6 +218,7 @@ class _OneTimeOrderForm extends StatelessWidget { final ValueChanged onVendorChanged; final ValueChanged onDateChanged; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -310,6 +326,18 @@ class _OneTimeOrderForm extends StatelessWidget { ), ), ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: labels.hub_manager_label, + description: labels.hub_manager_desc, + hintText: labels.hub_manager_hint, + noManagersText: labels.hub_manager_empty, + noneText: labels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), OneTimeOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart index 48931710..ea6680af 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart @@ -94,3 +94,19 @@ class OrderPositionUiModel extends Equatable { @override List get props => [role, count, startTime, endTime, lunchBreak]; } + +class OrderManagerUiModel extends Equatable { + const OrderManagerUiModel({ + required this.id, + required this.name, + this.phone, + }); + + final String id; + final String name; + final String? phone; + + @override + List get props => [id, name, phone]; +} + diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index c33d3641..abcf7a20 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'permanent_order_date_picker.dart'; import 'permanent_order_event_name_input.dart'; import 'permanent_order_header.dart'; @@ -24,12 +25,15 @@ class PermanentOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, required this.onStartDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -48,6 +52,8 @@ class PermanentOrderView extends StatelessWidget { final List permanentDays; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; final bool isValid; @@ -57,6 +63,7 @@ class PermanentOrderView extends StatelessWidget { final ValueChanged onStartDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -156,9 +163,12 @@ class PermanentOrderView extends StatelessWidget { onStartDateChanged: onStartDateChanged, onDayToggled: onDayToggled, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), if (status == OrderFormStatus.loading) const Center(child: CircularProgressIndicator()), @@ -194,9 +204,12 @@ class _PermanentOrderForm extends StatelessWidget { required this.onStartDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, }); final String eventName; @@ -214,10 +227,14 @@ class _PermanentOrderForm extends StatelessWidget { final ValueChanged onStartDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + @override Widget build(BuildContext context) { final TranslationsClientCreateOrderPermanentEn labels = @@ -331,6 +348,18 @@ class _PermanentOrderForm extends StatelessWidget { ), ), ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), PermanentOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index 18c01872..fbc00c07 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -3,6 +3,7 @@ import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'recurring_order_date_picker.dart'; import 'recurring_order_event_name_input.dart'; import 'recurring_order_header.dart'; @@ -25,6 +26,8 @@ class RecurringOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, @@ -32,6 +35,7 @@ class RecurringOrderView extends StatelessWidget { required this.onEndDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -51,6 +55,8 @@ class RecurringOrderView extends StatelessWidget { final List recurringDays; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; final bool isValid; @@ -61,6 +67,7 @@ class RecurringOrderView extends StatelessWidget { final ValueChanged onEndDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -165,9 +172,12 @@ class RecurringOrderView extends StatelessWidget { onEndDateChanged: onEndDateChanged, onDayToggled: onDayToggled, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), if (status == OrderFormStatus.loading) const Center(child: CircularProgressIndicator()), @@ -205,9 +215,12 @@ class _RecurringOrderForm extends StatelessWidget { required this.onEndDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, }); final String eventName; @@ -227,10 +240,15 @@ class _RecurringOrderForm extends StatelessWidget { final ValueChanged onEndDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + + @override Widget build(BuildContext context) { final TranslationsClientCreateOrderRecurringEn labels = @@ -351,6 +369,18 @@ class _RecurringOrderForm extends StatelessWidget { ), ), ), + const SizedBox(height: UiConstants.space4), + + HubManagerSelector( + label: oneTimeLabels.hub_manager_label, + description: oneTimeLabels.hub_manager_desc, + hintText: oneTimeLabels.hub_manager_hint, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), RecurringOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc index e71a16d2..64a0ecea 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake index 2e1de87a..2db3c22a 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 8bd29968..8a0af98d 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,16 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc index d141b74f..5861e0f0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake index 29944d5b..ce851e9d 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core ) diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index 5d1606fa..a8cd6843 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart'; @@ -57,6 +58,9 @@ class OrderEditSheetState extends State { const []; dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; + List _managers = const []; + dc.ListTeamMembersTeamMembers? _selectedManager; + String? _shiftId; List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[]; @@ -246,6 +250,9 @@ class OrderEditSheetState extends State { } }); } + if (selected != null) { + await _loadManagersForHub(selected.id, widget.order.hubManagerId); + } } catch (_) { if (mounted) { setState(() { @@ -331,6 +338,47 @@ class OrderEditSheetState extends State { } } + Future _loadManagersForHub(String hubId, [String? preselectedId]) async { + try { + final QueryResult result = + await _dataConnect.listTeamMembers().execute(); + + final List hubManagers = result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .toList(); + + dc.ListTeamMembersTeamMembers? selected; + if (preselectedId != null && preselectedId.isNotEmpty) { + for (final dc.ListTeamMembersTeamMembers m in hubManagers) { + if (m.id == preselectedId) { + selected = m; + break; + } + } + } + + if (mounted) { + setState(() { + _managers = hubManagers; + _selectedManager = selected; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _managers = const []; + _selectedManager = null; + }); + } + } + } + Map _emptyPosition() { return { 'shiftId': _shiftId, @@ -639,7 +687,7 @@ class OrderEditSheetState extends State { padding: const EdgeInsets.all(UiConstants.space5), children: [ Text( - 'Edit Your Order', + t.client_view_orders.order_edit_sheet.title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space4), @@ -697,7 +745,7 @@ class OrderEditSheetState extends State { _buildSectionHeader('ORDER NAME'), UiTextField( controller: _orderNameController, - hintText: 'Order name', + hintText: t.client_view_orders.order_edit_sheet.order_name_hint, prefixIcon: UiIcons.briefcase, ), const SizedBox(height: UiConstants.space4), @@ -744,13 +792,17 @@ class OrderEditSheetState extends State { ), ), ), + const SizedBox(height: UiConstants.space4), + + _buildHubManagerSelector(), + const SizedBox(height: UiConstants.space6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'POSITIONS', + t.client_view_orders.order_edit_sheet.positions_section, style: UiTypography.headline4m.textPrimary, ), TextButton( @@ -770,7 +822,7 @@ class OrderEditSheetState extends State { color: UiColors.primary, ), Text( - 'Add Position', + t.client_view_orders.order_edit_sheet.add_position, style: UiTypography.body2m.primary, ), ], @@ -791,7 +843,7 @@ class OrderEditSheetState extends State { ), ), _buildBottomAction( - label: 'Review ${_positions.length} Positions', + label: t.client_view_orders.order_edit_sheet.review_positions(count: _positions.length.toString()), onPressed: () => setState(() => _showReview = true), ), const Padding( @@ -807,6 +859,132 @@ class OrderEditSheetState extends State { ); } + Widget _buildHubManagerSelector() { + final TranslationsClientViewOrdersOrderEditSheetEn oes = + t.client_view_orders.order_edit_sheet; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(oes.shift_contact_section), + Text(oes.shift_contact_desc, style: UiTypography.body2r.textSecondary), + const SizedBox(height: UiConstants.space2), + InkWell( + onTap: () => _showHubManagerSelector(), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: _selectedManager != null ? UiColors.primary : UiColors.border, + width: _selectedManager != null ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + UiIcons.user, + color: _selectedManager != null + ? UiColors.primary + : UiColors.iconSecondary, + size: 20, + ), + const SizedBox(width: UiConstants.space3), + Text( + _selectedManager?.user.fullName ?? oes.select_contact, + style: _selectedManager != null + ? UiTypography.body1r.textPrimary + : UiTypography.body2r.textPlaceholder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + const Icon( + Icons.keyboard_arrow_down, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], + ); + } + + Future _showHubManagerSelector() async { + final dc.ListTeamMembersTeamMembers? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_view_orders.order_edit_sheet.shift_contact_section, + style: UiTypography.headline3m.textPrimary, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + shrinkWrap: true, + itemCount: _managers.isEmpty ? 2 : _managers.length + 1, + itemBuilder: (BuildContext context, int index) { + if (_managers.isEmpty) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(t.client_view_orders.order_edit_sheet.no_hub_managers), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop(null), + ); + } + + if (index == _managers.length) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(t.client_view_orders.order_edit_sheet.none, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop(null), + ); + } + final dc.ListTeamMembersTeamMembers manager = _managers[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + title: Text(manager.user.fullName ?? 'Unknown', style: UiTypography.body1m.textPrimary), + onTap: () => Navigator.of(context).pop(manager), + ); + }, + ), + ), + ), + ); + }, + ); + + if (mounted) { + if (selected == null && _managers.isEmpty) { + // Tapped outside or selected None + setState(() => _selectedManager = null); + } else { + setState(() => _selectedManager = selected); + } + } + } + Widget _buildHeader() { return Container( padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), @@ -839,11 +1017,11 @@ class OrderEditSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'One-Time Order', + t.client_view_orders.order_edit_sheet.one_time_order_title, style: UiTypography.headline3m.copyWith(color: UiColors.white), ), Text( - 'Refine your staffing needs', + t.client_view_orders.order_edit_sheet.refine_subtitle, style: UiTypography.footnote2r.copyWith( color: UiColors.white.withValues(alpha: 0.8), ), @@ -885,7 +1063,7 @@ class OrderEditSheetState extends State { GestureDetector( onTap: () => _removePosition(index), child: Text( - 'Remove', + t.client_view_orders.order_edit_sheet.remove, style: UiTypography.footnote1m.copyWith( color: UiColors.destructive, ), @@ -896,7 +1074,7 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space3), _buildDropdownField( - hint: 'Select role', + hint: t.client_view_orders.order_edit_sheet.select_role_hint, value: pos['roleId'], items: [ ..._roles.map((_RoleOption role) => role.id), @@ -931,14 +1109,14 @@ class OrderEditSheetState extends State { children: [ Expanded( child: _buildInlineTimeInput( - label: 'Start', + label: t.client_view_orders.order_edit_sheet.start_label, value: pos['start_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'start_time', @@ -951,14 +1129,14 @@ class OrderEditSheetState extends State { const SizedBox(width: UiConstants.space2), Expanded( child: _buildInlineTimeInput( - label: 'End', + label: t.client_view_orders.order_edit_sheet.end_label, value: pos['end_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'end_time', @@ -974,7 +1152,7 @@ class OrderEditSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Workers', + t.client_view_orders.order_edit_sheet.workers_label, style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space1), @@ -1029,7 +1207,7 @@ class OrderEditSheetState extends State { const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), const SizedBox(width: UiConstants.space1), Text( - 'Use different location for this position', + t.client_view_orders.order_edit_sheet.different_location, style: UiTypography.footnote1m.copyWith( color: UiColors.primary, ), @@ -1053,7 +1231,7 @@ class OrderEditSheetState extends State { ), const SizedBox(width: UiConstants.space1), Text( - 'Different Location', + t.client_view_orders.order_edit_sheet.different_location_title, style: UiTypography.footnote1m.textSecondary, ), ], @@ -1071,7 +1249,7 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space2), UiTextField( controller: TextEditingController(text: pos['location']), - hintText: 'Enter different address', + hintText: t.client_view_orders.order_edit_sheet.enter_address_hint, onChanged: (String val) => _updatePosition(index, 'location', val), ), @@ -1082,7 +1260,7 @@ class OrderEditSheetState extends State { _buildSectionHeader('LUNCH BREAK'), _buildDropdownField( - hint: 'No Break', + hint: t.client_view_orders.order_edit_sheet.no_break, value: pos['lunch_break'], items: [ 'NO_BREAK', @@ -1105,7 +1283,7 @@ class OrderEditSheetState extends State { case 'MIN_60': return '60 min (Unpaid)'; default: - return 'No Break'; + return t.client_view_orders.order_edit_sheet.no_break; } }, onChanged: (dynamic val) => @@ -1263,11 +1441,11 @@ class OrderEditSheetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSummaryItem('${_positions.length}', 'Positions'), - _buildSummaryItem('$totalWorkers', 'Workers'), + _buildSummaryItem('${_positions.length}', t.client_view_orders.order_edit_sheet.positions), + _buildSummaryItem('$totalWorkers', t.client_view_orders.order_edit_sheet.workers), _buildSummaryItem( '\$${totalCost.round()}', - 'Est. Cost', + t.client_view_orders.order_edit_sheet.est_cost, ), ], ), @@ -1326,7 +1504,7 @@ class OrderEditSheetState extends State { const SizedBox(height: 24), Text( - 'Positions Breakdown', + t.client_view_orders.order_edit_sheet.positions_breakdown, style: UiTypography.body2b.textPrimary, ), const SizedBox(height: 12), @@ -1357,14 +1535,14 @@ class OrderEditSheetState extends State { children: [ Expanded( child: UiButton.secondary( - text: 'Edit', + text: t.client_view_orders.order_edit_sheet.edit_button, onPressed: () => setState(() => _showReview = false), ), ), const SizedBox(width: 12), Expanded( child: UiButton.primary( - text: 'Confirm & Save', + text: t.client_view_orders.order_edit_sheet.confirm_save, onPressed: () async { setState(() => _isLoading = true); await _saveOrderChanges(); @@ -1426,7 +1604,7 @@ class OrderEditSheetState extends State { children: [ Text( (role?.name ?? pos['roleName']?.toString() ?? '').isEmpty - ? 'Position' + ? t.client_view_orders.order_edit_sheet.position_singular : (role?.name ?? pos['roleName']?.toString() ?? ''), style: UiTypography.body2b.textPrimary, ), @@ -1492,14 +1670,14 @@ class OrderEditSheetState extends State { ), const SizedBox(height: 24), Text( - 'Order Updated!', + t.client_view_orders.order_edit_sheet.order_updated_title, style: UiTypography.headline1m.copyWith(color: UiColors.white), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( - 'Your shift has been updated successfully.', + t.client_view_orders.order_edit_sheet.order_updated_message, textAlign: TextAlign.center, style: UiTypography.body1r.copyWith( color: UiColors.white.withValues(alpha: 0.7), @@ -1510,7 +1688,7 @@ class OrderEditSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: UiButton.secondary( - text: 'Back to Orders', + text: t.client_view_orders.order_edit_sheet.back_to_orders, fullWidth: true, style: OutlinedButton.styleFrom( backgroundColor: UiColors.white, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index e4c215ac..b5f02c97 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -259,6 +259,31 @@ class _ViewOrderCardState extends State { ), ], ), + if (order.hubManagerName != null) ...[ + const SizedBox(height: UiConstants.space2), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon( + UiIcons.user, + size: 14, + color: UiColors.iconSecondary, + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: Text( + order.hubManagerName!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], ], ), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 7db4d5ab..0950c573 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -24,15 +24,52 @@ class SettingsActions extends StatelessWidget { delegate: SliverChildListDelegate([ const SizedBox(height: UiConstants.space5), + // Edit Profile button (Yellow) + UiButton.primary( + text: labels.edit_profile, + fullWidth: true, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), + onPressed: () => Modular.to.toClientEditProfile(), + ), + const SizedBox(height: UiConstants.space4), + + // Hubs button (Yellow) + UiButton.primary( + text: labels.hubs, + fullWidth: true, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), + onPressed: () => Modular.to.toClientHubs(), + ), + const SizedBox(height: UiConstants.space5), + // Quick Links card _QuickLinksCard(labels: labels), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space5), // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { return UiButton.secondary( text: labels.log_out, + fullWidth: true, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: UiColors.black), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2), + ), + ), onPressed: state is ClientSettingsLoading ? null : () => _showSignOutDialog(context), @@ -113,7 +150,7 @@ class _QuickLinksCard extends StatelessWidget { onTap: () => Modular.to.toClientHubs(), ), _QuickLinkItem( - icon: UiIcons.building, + icon: UiIcons.file, title: labels.billing_payments, onTap: () => Modular.to.toClientBilling(), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index 1efc5139..ea359254 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../../blocs/client_settings_bloc.dart'; /// A widget that displays the log out button. @@ -58,7 +59,7 @@ class SettingsLogout extends StatelessWidget { style: UiTypography.headline3m.textPrimary, ), content: Text( - 'Are you sure you want to log out?', + t.client_settings.profile.log_out_confirmation, style: UiTypography.body2r.textSecondary, ), actions: [ diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index c6987214..dd746425 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -31,7 +31,7 @@ class SettingsProfileHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // ── Top bar: back arrow + title ────────────────── + // ── Top bar: back arrow + centered title ───────── SafeArea( bottom: false, child: Padding( @@ -39,21 +39,25 @@ class SettingsProfileHeader extends StatelessWidget { horizontal: UiConstants.space4, vertical: UiConstants.space2, ), - child: Row( + child: Stack( + alignment: Alignment.center, children: [ - GestureDetector( - onTap: () => Modular.to.toClientHome(), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.white, - size: 22, + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 22, + ), ), ), - const SizedBox(width: UiConstants.space3), Text( labels.title, style: UiTypography.body1b.copyWith( color: UiColors.white, + fontSize: 18, ), ), ], diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart index 05fa1f30..1d757a3f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/otp_verification/otp_input_field.dart @@ -1,4 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -75,10 +75,7 @@ class _OtpInputFieldState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(6, (int index) { - return SizedBox( - width: 45, - height: 56, - child: TextField( + final TextField field = TextField( controller: _controllers[index], focusNode: _focusNodes[index], keyboardType: TextInputType.number, @@ -112,7 +109,16 @@ class _OtpInputFieldState extends State { ), onChanged: (String value) => _onChanged(context: context, index: index, value: value), - ), + ); + return SizedBox( + width: 45, + height: 56, + child: index == 0 + ? Semantics( + identifier: 'staff_otp_input', + child: field, + ) + : field, ); }), ), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart index 256e4f7b..656f1877 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart @@ -82,17 +82,20 @@ class _PhoneInputFormFieldState extends State { ), const SizedBox(width: UiConstants.space2), Expanded( - child: TextField( - controller: _controller, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(11), - ], - decoration: InputDecoration( - hintText: t.staff_authentication.phone_input.hint, + child: Semantics( + identifier: 'staff_phone_input', + child: TextField( + controller: _controller, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(11), + ], + decoration: InputDecoration( + hintText: t.staff_authentication.phone_input.hint, + ), + onChanged: widget.onChanged, ), - onChanged: widget.onChanged, ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index eb32cf88..f574b6d1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,5 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; @@ -8,11 +10,20 @@ import 'domain/repositories/attire_repository.dart'; import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart'; +import 'presentation/pages/attire_capture_page.dart'; import 'presentation/pages/attire_page.dart'; class StaffAttireModule extends Module { @override void binds(Injector i) { + /// third party services + i.addLazySingleton(ImagePicker.new); + + /// local services + i.addLazySingleton( + () => CameraService(i.get()), + ); + // Repository i.addLazySingleton(AttireRepositoryImpl.new); @@ -22,7 +33,7 @@ class StaffAttireModule extends Module { i.addLazySingleton(UploadAttirePhotoUseCase.new); // BLoC - i.addLazySingleton(AttireCubit.new); + i.add(AttireCubit.new); i.add(AttireCaptureCubit.new); } @@ -32,5 +43,12 @@ class StaffAttireModule extends Module { StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attire), child: (_) => const AttirePage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture), + child: (_) => AttireCapturePage( + item: r.args.data['item'] as AttireItem, + initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?, + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index 727c8f77..65645ad8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -1,4 +1,8 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' + hide AttireVerificationStatus; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/attire_repository.dart'; @@ -31,16 +35,97 @@ class AttireRepositoryImpl implements AttireRepository { } @override - Future uploadPhoto(String itemId) async { - // In a real app, this would upload to Firebase Storage first. - // Since the prototype returns a mock URL, we'll use that to upsert our record. - final String mockUrl = 'mock_url_for_$itemId'; - - await _connector.upsertStaffAttire( - attireOptionId: itemId, - photoUrl: mockUrl, + Future uploadPhoto(String itemId, String filePath) async { + // 1. Upload file to Core API + final FileUploadService uploadService = Modular.get(); + final FileUploadResponse uploadRes = await uploadService.uploadFile( + filePath: filePath, + fileName: filePath.split('/').last, ); - return mockUrl; + final String fileUri = uploadRes.fileUri; + + // 2. Create signed URL for the uploaded file + final SignedUrlService signedUrlService = Modular.get(); + final SignedUrlResponse signedUrlRes = await signedUrlService + .createSignedUrl(fileUri: fileUri); + final String photoUrl = signedUrlRes.signedUrl; + + // 3. Initiate verification job + final VerificationService verificationService = + Modular.get(); + final Staff staff = await _connector.getStaffProfile(); + + // Get item details for verification rules + final List options = await _connector.getAttireOptions(); + final AttireItem targetItem = options.firstWhere( + (AttireItem e) => e.id == itemId, + ); + final String dressCode = + '${targetItem.description ?? ''} ${targetItem.label}'.trim(); + + final VerificationResponse verifyRes = await verificationService + .createVerification( + type: 'attire', + subjectType: 'worker', + subjectId: staff.id, + fileUri: fileUri, + rules: {'dressCode': dressCode}, + ); + final String verificationId = verifyRes.verificationId; + VerificationStatus currentStatus = verifyRes.status; + + // 4. Poll for status until it's finished or timeout (max 10 seconds) + try { + int attempts = 0; + bool isFinished = false; + while (!isFinished && attempts < 5) { + await Future.delayed(const Duration(seconds: 2)); + final VerificationResponse statusRes = await verificationService + .getStatus(verificationId); + currentStatus = statusRes.status; + if (currentStatus != VerificationStatus.pending && + currentStatus != VerificationStatus.processing) { + isFinished = true; + } + attempts++; + } + } catch (e) { + debugPrint('Polling failed or timed out: $e'); + // Continue anyway, as we have the verificationId + } + + // 5. Update Data Connect + await _connector.upsertStaffAttire( + attireOptionId: itemId, + photoUrl: photoUrl, + verificationId: verificationId, + verificationStatus: _mapToAttireStatus(currentStatus), + ); + + // 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status + final List finalOptions = await _connector.getAttireOptions(); + return finalOptions.firstWhere((AttireItem e) => e.id == itemId); + } + + AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) { + switch (status) { + case VerificationStatus.pending: + return AttireVerificationStatus.pending; + case VerificationStatus.processing: + return AttireVerificationStatus.processing; + case VerificationStatus.autoPass: + return AttireVerificationStatus.autoPass; + case VerificationStatus.autoFail: + return AttireVerificationStatus.autoFail; + case VerificationStatus.needsReview: + return AttireVerificationStatus.needsReview; + case VerificationStatus.approved: + return AttireVerificationStatus.approved; + case VerificationStatus.rejected: + return AttireVerificationStatus.rejected; + case VerificationStatus.error: + return AttireVerificationStatus.error; + } } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart index 1745879c..dafdac1f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart @@ -7,10 +7,17 @@ class UploadAttirePhotoArguments extends UseCaseArgument { // We'll stick to that signature for now to "preserve behavior". /// Creates a [UploadAttirePhotoArguments]. - const UploadAttirePhotoArguments({required this.itemId}); + const UploadAttirePhotoArguments({ + required this.itemId, + required this.filePath, + }); + /// The ID of the attire item being uploaded. final String itemId; + /// The local path to the photo file. + final String filePath; + @override - List get props => [itemId]; + List get props => [itemId, filePath]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart index 1b4742ad..a57107c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -4,8 +4,8 @@ abstract interface class AttireRepository { /// Fetches the list of available attire options. Future> getAttireOptions(); - /// Simulates uploading a photo for a specific attire item. - Future uploadPhoto(String itemId); + /// Uploads a photo for a specific attire item. + Future uploadPhoto(String itemId, String filePath); /// Saves the user's attire selection and attestations. Future saveAttire({ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart index 7c6de30a..39cd456b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -1,16 +1,17 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../arguments/upload_attire_photo_arguments.dart'; import '../repositories/attire_repository.dart'; /// Use case to upload a photo for an attire item. -class UploadAttirePhotoUseCase extends UseCase { - +class UploadAttirePhotoUseCase + extends UseCase { /// Creates a [UploadAttirePhotoUseCase]. UploadAttirePhotoUseCase(this._repository); final AttireRepository _repository; @override - Future call(UploadAttirePhotoArguments arguments) { - return _repository.uploadPhoto(arguments.itemId); + Future call(UploadAttirePhotoArguments arguments) { + return _repository.uploadPhoto(arguments.itemId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart index ce9862d5..bc643b5a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -64,9 +64,25 @@ class AttireCubit extends Cubit emit(state.copyWith(selectedIds: currentSelection)); } - void syncCapturedPhoto(String itemId, String url) { - // When a photo is captured, we refresh the options to get the updated status from backend - loadOptions(); + void updateFilter(String filter) { + emit(state.copyWith(filter: filter)); + } + + void syncCapturedPhoto(AttireItem item) { + // Update the options list with the new item data + final List updatedOptions = state.options + .map((AttireItem e) => e.id == item.id ? item : e) + .toList(); + + // Update the photo URLs map + final Map updatedPhotos = Map.from( + state.photoUrls, + ); + if (item.photoUrl != null) { + updatedPhotos[item.id] = item.photoUrl!; + } + + emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos)); } Future save() async { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index 3d882c07..e137aff2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -9,12 +9,14 @@ class AttireState extends Equatable { this.options = const [], this.selectedIds = const [], this.photoUrls = const {}, + this.filter = 'All', this.errorMessage, }); final AttireStatus status; final List options; final List selectedIds; final Map photoUrls; + final String filter; final String? errorMessage; /// Helper to check if item is mandatory @@ -22,7 +24,7 @@ class AttireState extends Equatable { return options .firstWhere( (AttireItem e) => e.id == id, - orElse: () => const AttireItem(id: '', label: ''), + orElse: () => const AttireItem(id: '', code: '', label: ''), ) .isMandatory; } @@ -44,11 +46,20 @@ class AttireState extends Equatable { bool get canSave => allMandatorySelected && allMandatoryHavePhotos; + List get filteredOptions { + return options.where((AttireItem item) { + if (filter == 'Required') return item.isMandatory; + if (filter == 'Non-Essential') return !item.isMandatory; + return true; + }).toList(); + } + AttireState copyWith({ AttireStatus? status, List? options, List? selectedIds, Map? photoUrls, + String? filter, String? errorMessage, }) { return AttireState( @@ -56,6 +67,7 @@ class AttireState extends Equatable { options: options ?? this.options, selectedIds: selectedIds ?? this.selectedIds, photoUrls: photoUrls ?? this.photoUrls, + filter: filter ?? this.filter, errorMessage: errorMessage, ); } @@ -66,6 +78,7 @@ class AttireState extends Equatable { options, selectedIds, photoUrls, + filter, errorMessage, ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart index 884abb37..a3b9eca1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; @@ -16,18 +17,22 @@ class AttireCaptureCubit extends Cubit emit(state.copyWith(isAttested: value)); } - Future uploadPhoto(String itemId) async { + Future uploadPhoto(String itemId, String filePath) async { emit(state.copyWith(status: AttireCaptureStatus.uploading)); await handleError( emit: emit, action: () async { - final String url = await _uploadAttirePhotoUseCase( - UploadAttirePhotoArguments(itemId: itemId), + final AttireItem item = await _uploadAttirePhotoUseCase( + UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), ); emit( - state.copyWith(status: AttireCaptureStatus.success, photoUrl: url), + state.copyWith( + status: AttireCaptureStatus.success, + photoUrl: item.photoUrl, + updatedItem: item, + ), ); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart index 6b776816..79f6e28a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; enum AttireCaptureStatus { initial, uploading, success, failure } @@ -7,24 +8,28 @@ class AttireCaptureState extends Equatable { this.status = AttireCaptureStatus.initial, this.isAttested = false, this.photoUrl, + this.updatedItem, this.errorMessage, }); final AttireCaptureStatus status; final bool isAttested; final String? photoUrl; + final AttireItem? updatedItem; final String? errorMessage; AttireCaptureState copyWith({ AttireCaptureStatus? status, bool? isAttested, String? photoUrl, + AttireItem? updatedItem, String? errorMessage, }) { return AttireCaptureState( status: status ?? this.status, isAttested: isAttested ?? this.isAttested, photoUrl: photoUrl ?? this.photoUrl, + updatedItem: updatedItem ?? this.updatedItem, errorMessage: errorMessage, ); } @@ -34,6 +39,7 @@ class AttireCaptureState extends Equatable { status, isAttested, photoUrl, + updatedItem, errorMessage, ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 5585f500..82109743 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -3,23 +3,28 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; -import '../widgets/attestation_checkbox.dart'; -import '../widgets/attire_capture_page/attire_image_preview.dart'; -import '../widgets/attire_capture_page/attire_upload_buttons.dart'; -import '../widgets/attire_capture_page/attire_verification_status_card.dart'; +import '../widgets/attire_capture_page/footer_section.dart'; +import '../widgets/attire_capture_page/image_preview_section.dart'; +import '../widgets/attire_capture_page/info_section.dart'; +/// The [AttireCapturePage] allows users to capture or upload a photo of a specific attire item. class AttireCapturePage extends StatefulWidget { + /// Creates an [AttireCapturePage]. const AttireCapturePage({ super.key, required this.item, this.initialPhotoUrl, }); + /// The attire item being captured. final AttireItem item; + + /// Optional initial photo URL if it was already uploaded. final String? initialPhotoUrl; @override @@ -27,21 +32,148 @@ class AttireCapturePage extends StatefulWidget { } class _AttireCapturePageState extends State { - void _onUpload(BuildContext context) { + String? _selectedLocalPath; + + /// Whether a verification status is already present for this item. + bool get _hasVerificationStatus => widget.item.verificationStatus != null; + + /// Whether the item is currently pending verification. + bool get _isPending => + widget.item.verificationStatus == AttireVerificationStatus.pending; + + /// On gallery button press + Future _onGallery(BuildContext context) async { final AttireCaptureCubit cubit = BlocProvider.of( context, ); - if (!cubit.state.isAttested) { - UiSnackbar.show( - context, - message: 'Please attest that you own this item.', - type: UiSnackbarType.error, - margin: const EdgeInsets.all(UiConstants.space4), - ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); return; } - // Call the upload via cubit - cubit.uploadPhoto(widget.item.id); + + try { + final GalleryService service = Modular.get(); + final String? path = await service.pickImage(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access gallery: $e'); + } + } + } + + /// On camera button press + Future _onCamera(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); + return; + } + + try { + final CameraService service = Modular.get(); + final String? path = await service.takePhoto(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access camera: $e'); + } + } + } + + /// Show a bottom sheet for reuploading options. + void _onReupload(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (BuildContext sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Gallery'), + onTap: () { + Modular.to.pop(); + _onGallery(context); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Camera'), + onTap: () { + Modular.to.pop(); + _onCamera(context); + }, + ), + ], + ), + ), + ); + } + + void _showAttestationWarning(BuildContext context) { + UiSnackbar.show( + context, + message: 'Please attest that you own this item.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + void _showError(BuildContext context, String message) { + debugPrint(message); + UiSnackbar.show( + context, + message: 'Could not access camera or gallery. Please try again.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + Future _onSubmit(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + if (_selectedLocalPath == null) return; + + await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!); + if (context.mounted && cubit.state.status == AttireCaptureStatus.success) { + setState(() { + _selectedLocalPath = null; + }); + } + } + + String _getStatusText(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', + AttireVerificationStatus.pending => 'Pending Verification', + _ => hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', + }; + } + + Color _getStatusColor(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => UiColors.textSuccess, + AttireVerificationStatus.rejected => UiColors.textError, + AttireVerificationStatus.pending => UiColors.textWarning, + _ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive, + }; } @override @@ -55,7 +187,12 @@ class _AttireCapturePageState extends State { ); return Scaffold( - appBar: UiAppBar(title: widget.item.label, showBackButton: true), + appBar: UiAppBar( + title: widget.item.label, + onLeadingPressed: () { + Modular.to.toAttire(); + }, + ), body: BlocConsumer( bloc: cubit, listener: (BuildContext context, AttireCaptureState state) { @@ -66,35 +203,21 @@ class _AttireCapturePageState extends State { type: UiSnackbarType.error, ); } + + if (state.status == AttireCaptureStatus.success) { + UiSnackbar.show( + context, + message: 'Attire image submitted for verification', + type: UiSnackbarType.success, + ); + Modular.to.toAttire(); + } }, builder: (BuildContext context, AttireCaptureState state) { - final bool isUploading = - state.status == AttireCaptureStatus.uploading; final String? currentPhotoUrl = state.photoUrl ?? widget.initialPhotoUrl; final bool hasUploadedPhoto = currentPhotoUrl != null; - final String statusText = switch (widget - .item - .verificationStatus) { - AttireVerificationStatus.success => 'Approved', - AttireVerificationStatus.failed => 'Rejected', - AttireVerificationStatus.pending => 'Pending Verification', - _ => - hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', - }; - - final Color statusColor = - switch (widget.item.verificationStatus) { - AttireVerificationStatus.success => UiColors.textSuccess, - AttireVerificationStatus.failed => UiColors.textError, - AttireVerificationStatus.pending => UiColors.textWarning, - _ => - hasUploadedPhoto - ? UiColors.textWarning - : UiColors.textInactive, - }; - return Column( children: [ Expanded( @@ -102,98 +225,39 @@ class _AttireCapturePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( children: [ - // Image Preview (Toggle between example and uploaded) - if (hasUploadedPhoto) ...[ - Text( - 'Your Uploaded Photo', - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - AttireImagePreview(imageUrl: currentPhotoUrl), - const SizedBox(height: UiConstants.space4), - Text( - 'Reference Example', - style: UiTypography.body2b.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.item.imageUrl ?? '', - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const SizedBox.shrink(), - ), - ), - ), - ] else ...[ - AttireImagePreview( - imageUrl: widget.item.imageUrl, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'Example of the item that you need to upload.', - style: UiTypography.body1b.textSecondary, - textAlign: TextAlign.center, - ), - ], - - const SizedBox(height: UiConstants.space6), - if (widget.item.description != null) - Text( - widget.item.description!, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space8), - - // Verification info - AttireVerificationStatusCard( - statusText: statusText, - statusColor: statusColor, + ImagePreviewSection( + selectedLocalPath: _selectedLocalPath, + currentPhotoUrl: currentPhotoUrl, + referenceImageUrl: widget.item.imageUrl, ), - const SizedBox(height: UiConstants.space6), - - AttestationCheckbox( - isChecked: state.isAttested, - onChanged: (bool? val) { + const SizedBox(height: UiConstants.space1), + InfoSection( + description: widget.item.description, + statusText: _getStatusText(hasUploadedPhoto), + statusColor: _getStatusColor(hasUploadedPhoto), + isPending: _isPending, + showCheckbox: !_hasVerificationStatus, + isAttested: state.isAttested, + onAttestationChanged: (bool? val) { cubit.toggleAttestation(val ?? false); }, ), - const SizedBox(height: UiConstants.space6), - - if (isUploading) - const Center( - child: Padding( - padding: EdgeInsets.all(UiConstants.space8), - child: CircularProgressIndicator(), - ), - ) - else - AttireUploadButtons(onUpload: _onUpload), ], ), ), ), - if (hasUploadedPhoto) - SafeArea( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: 'Submit Image', - onPressed: () { - Modular.to.pop(currentPhotoUrl); - }, - ), - ), - ), - ), + FooterSection( + isUploading: + state.status == AttireCaptureStatus.uploading, + selectedLocalPath: _selectedLocalPath, + hasVerificationStatus: _hasVerificationStatus, + hasUploadedPhoto: hasUploadedPhoto, + updatedItem: state.updatedItem, + onGallery: () => _onGallery(context), + onCamera: () => _onCamera(context), + onSubmit: () => _onSubmit(context), + onReupload: () => _onReupload(context), + ), ], ); }, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index c2782981..280fd344 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; @@ -10,18 +11,10 @@ import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; import '../widgets/attire_filter_chips.dart'; import '../widgets/attire_info_card.dart'; import '../widgets/attire_item_card.dart'; -import 'attire_capture_page.dart'; -class AttirePage extends StatefulWidget { +class AttirePage extends StatelessWidget { const AttirePage({super.key}); - @override - State createState() => _AttirePageState(); -} - -class _AttirePageState extends State { - String _filter = 'All'; - @override Widget build(BuildContext context) { final AttireCubit cubit = Modular.get(); @@ -30,6 +23,7 @@ class _AttirePageState extends State { appBar: UiAppBar( title: t.staff_profile_attire.title, showBackButton: true, + onLeadingPressed: () => Modular.to.toProfile(), ), body: BlocProvider.value( value: cubit, @@ -48,14 +42,7 @@ class _AttirePageState extends State { return const Center(child: CircularProgressIndicator()); } - final List options = state.options; - final List filteredOptions = options.where(( - AttireItem item, - ) { - if (_filter == 'Required') return item.isMandatory; - if (_filter == 'Non-Essential') return !item.isMandatory; - return true; - }).toList(); + final List filteredOptions = state.filteredOptions; return Column( children: [ @@ -70,12 +57,8 @@ class _AttirePageState extends State { // Filter Chips AttireFilterChips( - selectedFilter: _filter, - onFilterChanged: (String value) { - setState(() { - _filter = value; - }); - }, + selectedFilter: state.filter, + onFilterChanged: cubit.updateFilter, ), const SizedBox(height: UiConstants.space6), @@ -112,23 +95,11 @@ class _AttirePageState extends State { item: item, isUploading: false, uploadedPhotoUrl: state.photoUrls[item.id], - onTap: () async { - final String? resultUrl = - await Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext ctx) => - AttireCapturePage( - item: item, - initialPhotoUrl: - state.photoUrls[item.id], - ), - ), - ); - - if (resultUrl != null && mounted) { - cubit.syncCapturedPhoto(item.id, resultUrl); - } + onTap: () { + Modular.to.toAttireCapture( + item: item, + initialPhotoUrl: state.photoUrls[item.id], + ); }, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart index 5adfeec2..0e670951 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart @@ -1,10 +1,23 @@ +import 'dart:io'; + import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; class AttireImagePreview extends StatelessWidget { - const AttireImagePreview({super.key, required this.imageUrl}); + const AttireImagePreview({super.key, this.imageUrl, this.localPath}); final String? imageUrl; + final String? localPath; + + ImageProvider get _imageProvider { + if (localPath != null) { + return FileImage(File(localPath!)); + } + return NetworkImage( + imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ); + } void _viewEnlargedImage(BuildContext context) { showDialog( @@ -17,10 +30,7 @@ class AttireImagePreview extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(UiConstants.radiusBase), image: DecorationImage( - image: NetworkImage( - imageUrl ?? - 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', - ), + image: _imageProvider, fit: BoxFit.contain, ), ), @@ -47,13 +57,7 @@ class AttireImagePreview extends StatelessWidget { offset: Offset(0, 2), ), ], - image: DecorationImage( - image: NetworkImage( - imageUrl ?? - 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', - ), - fit: BoxFit.cover, - ), + image: DecorationImage(image: _imageProvider, fit: BoxFit.cover), ), child: const Align( alignment: Alignment.bottomRight, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart index 83067e7e..e6bcb712 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart @@ -2,9 +2,14 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; class AttireUploadButtons extends StatelessWidget { - const AttireUploadButtons({super.key, required this.onUpload}); + const AttireUploadButtons({ + super.key, + required this.onGallery, + required this.onCamera, + }); - final void Function(BuildContext) onUpload; + final VoidCallback onGallery; + final VoidCallback onCamera; @override Widget build(BuildContext context) { @@ -14,7 +19,7 @@ class AttireUploadButtons extends StatelessWidget { child: UiButton.secondary( leadingIcon: UiIcons.gallery, text: 'Gallery', - onPressed: () => onUpload(context), + onPressed: onGallery, ), ), const SizedBox(width: UiConstants.space4), @@ -22,7 +27,7 @@ class AttireUploadButtons extends StatelessWidget { child: UiButton.primary( leadingIcon: UiIcons.camera, text: 'Camera', - onPressed: () => onUpload(context), + onPressed: onCamera, ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart new file mode 100644 index 00000000..6f0b4c2e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -0,0 +1,109 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'attire_upload_buttons.dart'; + +/// Handles the primary actions at the bottom of the page. +class FooterSection extends StatelessWidget { + /// Creates a [FooterSection]. + const FooterSection({ + super.key, + required this.isUploading, + this.selectedLocalPath, + required this.hasVerificationStatus, + required this.hasUploadedPhoto, + this.updatedItem, + required this.onGallery, + required this.onCamera, + required this.onSubmit, + required this.onReupload, + }); + + /// Whether a photo is currently being uploaded. + final bool isUploading; + + /// The local path of the selected photo. + final String? selectedLocalPath; + + /// Whether the item already has a verification status. + final bool hasVerificationStatus; + + /// Whether the item has an uploaded photo. + final bool hasUploadedPhoto; + + /// The updated attire item, if any. + final AttireItem? updatedItem; + + /// Callback to open the gallery. + final VoidCallback onGallery; + + /// Callback to open the camera. + final VoidCallback onCamera; + + /// Callback to submit the photo. + final VoidCallback onSubmit; + + /// Callback to trigger the re-upload flow. + final VoidCallback onReupload; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isUploading) + const Center( + child: Padding( + padding: EdgeInsets.all(UiConstants.space4), + child: CircularProgressIndicator(), + ), + ) + else + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + if (selectedLocalPath != null) { + return UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: onSubmit, + ); + } + + if (hasVerificationStatus) { + return UiButton.secondary( + fullWidth: true, + text: 'Re Upload', + onPressed: onReupload, + ); + } + + return Column( + children: [ + AttireUploadButtons(onGallery: onGallery, onCamera: onCamera), + if (hasUploadedPhoto) ...[ + const SizedBox(height: UiConstants.space4), + UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: () { + if (updatedItem != null) { + Modular.to.pop(updatedItem); + } + }, + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart new file mode 100644 index 00000000..18a6e930 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart @@ -0,0 +1,96 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_image_preview.dart'; + +/// Displays the comparison between the reference example and the user's photo. +class ImagePreviewSection extends StatelessWidget { + /// Creates an [ImagePreviewSection]. + const ImagePreviewSection({ + super.key, + this.selectedLocalPath, + this.currentPhotoUrl, + this.referenceImageUrl, + }); + + /// The local file path of the selected image. + final String? selectedLocalPath; + + /// The URL of the currently uploaded photo. + final String? currentPhotoUrl; + + /// The URL of the reference example image. + final String? referenceImageUrl; + + @override + Widget build(BuildContext context) { + if (selectedLocalPath != null) { + return Column( + children: [ + Text( + 'Review the attire item', + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(localPath: selectedLocalPath), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + if (currentPhotoUrl != null) { + return Column( + children: [ + Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(imageUrl: currentPhotoUrl), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + return Column( + children: [ + AttireImagePreview(imageUrl: referenceImageUrl), + const SizedBox(height: UiConstants.space4), + Text( + 'Example of the item that you need to upload.', + style: UiTypography.body1b.textSecondary, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +/// Displays the reference item photo as an example. +class ReferenceExample extends StatelessWidget { + /// Creates a [ReferenceExample]. + const ReferenceExample({super.key, this.imageUrl}); + + /// The URL of the image to display. + final String? imageUrl; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Reference Example', style: UiTypography.body2b.textSecondary), + const SizedBox(height: UiConstants.space1), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Image.network( + imageUrl ?? '', + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart new file mode 100644 index 00000000..be5995f2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart @@ -0,0 +1,89 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../attestation_checkbox.dart'; +import 'attire_verification_status_card.dart'; + +/// Displays the item details, verification status, and attestation checkbox. +class InfoSection extends StatelessWidget { + /// Creates an [InfoSection]. + const InfoSection({ + super.key, + this.description, + required this.statusText, + required this.statusColor, + required this.isPending, + required this.showCheckbox, + required this.isAttested, + required this.onAttestationChanged, + }); + + /// The description of the attire item. + final String? description; + + /// The text to display for the verification status. + final String statusText; + + /// The color to use for the verification status text. + final Color statusColor; + + /// Whether the item is currently pending verification. + final bool isPending; + + /// Whether to show the attestation checkbox. + final bool showCheckbox; + + /// Whether the user has attested to owning the item. + final bool isAttested; + + /// Callback when the attestation status changes. + final ValueChanged onAttestationChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (description != null) + Text( + description!, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space8), + + // Pending Banner + if (isPending) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.tagPending, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Text( + 'A Manager will Verify This Item', + style: UiTypography.body2b.textWarning, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: UiConstants.space4), + ], + + // Verification info + AttireVerificationStatusCard( + statusText: statusText, + statusColor: statusColor, + ), + const SizedBox(height: UiConstants.space6), + + if (showCheckbox) ...[ + AttestationCheckbox( + isChecked: isAttested, + onChanged: onAttestationChanged, + ), + const SizedBox(height: UiConstants.space6), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart index 43c88fbc..f0941d96 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -3,11 +3,6 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; class AttireItemCard extends StatelessWidget { - final AttireItem item; - final String? uploadedPhotoUrl; - final bool isUploading; - final VoidCallback onTap; - const AttireItemCard({ super.key, required this.item, @@ -16,12 +11,17 @@ class AttireItemCard extends StatelessWidget { required this.onTap, }); + final AttireItem item; + final String? uploadedPhotoUrl; + final bool isUploading; + final VoidCallback onTap; + @override Widget build(BuildContext context) { final bool hasPhoto = item.photoUrl != null; final String statusText = switch (item.verificationStatus) { - AttireVerificationStatus.success => 'Approved', - AttireVerificationStatus.failed => 'Rejected', + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', AttireVerificationStatus.pending => 'Pending', _ => hasPhoto ? 'Pending' : 'To Do', }; @@ -89,7 +89,9 @@ class AttireItemCard extends StatelessWidget { UiChip( label: statusText, size: UiChipSize.xSmall, - variant: item.verificationStatus == 'SUCCESS' + variant: + item.verificationStatus == + AttireVerificationStatus.approved ? UiChipVariant.primary : UiChipVariant.secondary, ), @@ -112,10 +114,12 @@ class AttireItemCard extends StatelessWidget { ) else if (hasPhoto && !isUploading) Icon( - item.verificationStatus == 'SUCCESS' + item.verificationStatus == AttireVerificationStatus.approved ? UiIcons.check : UiIcons.clock, - color: item.verificationStatus == 'SUCCESS' + color: + item.verificationStatus == + AttireVerificationStatus.approved ? UiColors.textPrimary : UiColors.textWarning, size: 24, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml index 07a124c8..0a5ffcf0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path: ../../../../../design_system core_localization: path: ../../../../../core_localization + image_picker: ^1.2.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 9aa8910e..07839283 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -337,6 +345,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_app_check: dependency: transitive description: @@ -725,6 +773,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -1496,6 +1608,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/backend/command-api/Dockerfile b/backend/command-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/command-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/command-api/package-lock.json b/backend/command-api/package-lock.json new file mode 100644 index 00000000..2b8f2d6c --- /dev/null +++ b/backend/command-api/package-lock.json @@ -0,0 +1,3035 @@ +{ + "name": "@krow/command-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/command-api", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.16.3", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", + "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/command-api/package.json b/backend/command-api/package.json new file mode 100644 index 00000000..c47230cf --- /dev/null +++ b/backend/command-api/package.json @@ -0,0 +1,25 @@ +{ + "name": "@krow/command-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test", + "migrate:idempotency": "node scripts/migrate-idempotency.mjs" + }, + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.16.3", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/command-api/scripts/migrate-idempotency.mjs b/backend/command-api/scripts/migrate-idempotency.mjs new file mode 100644 index 00000000..42970b84 --- /dev/null +++ b/backend/command-api/scripts/migrate-idempotency.mjs @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Pool } from 'pg'; + +const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL; + +if (!databaseUrl) { + // eslint-disable-next-line no-console + console.error('IDEMPOTENCY_DATABASE_URL is required'); + process.exit(1); +} + +const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url))); +const migrationPath = resolve(scriptDir, '../sql/001_command_idempotency.sql'); +const sql = readFileSync(migrationPath, 'utf8'); + +const pool = new Pool({ + connectionString: databaseUrl, + max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10), +}); + +try { + await pool.query(sql); + // eslint-disable-next-line no-console + console.log('Idempotency migration applied successfully'); +} finally { + await pool.end(); +} diff --git a/backend/command-api/sql/001_command_idempotency.sql b/backend/command-api/sql/001_command_idempotency.sql new file mode 100644 index 00000000..373ec468 --- /dev/null +++ b/backend/command-api/sql/001_command_idempotency.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS command_idempotency ( + composite_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + route TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at + ON command_idempotency (expires_at); diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js new file mode 100644 index 00000000..c6a72078 --- /dev/null +++ b/backend/command-api/src/app.js @@ -0,0 +1,30 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createCommandsRouter } from './routes/commands.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp() { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + app.use(express.json({ limit: '2mb' })); + + app.use(healthRouter); + app.use('/commands', createCommandsRouter()); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/command-api/src/contracts/commands/command-base.js b/backend/command-api/src/contracts/commands/command-base.js new file mode 100644 index 00000000..811e470e --- /dev/null +++ b/backend/command-api/src/contracts/commands/command-base.js @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const commandBaseSchema = z.object({ + payload: z.record(z.any()).optional(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/lib/errors.js b/backend/command-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/command-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/command-api/src/middleware/auth.js b/backend/command-api/src/middleware/auth.js new file mode 100644 index 00000000..9c62c86d --- /dev/null +++ b/backend/command-api/src/middleware/auth.js @@ -0,0 +1,45 @@ +import { AppError } from '../lib/errors.js'; +import { can } from '../services/policy.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +export async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function requirePolicy(action, resource) { + return (req, _res, next) => { + if (!can(action, resource, req.actor)) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + }; +} diff --git a/backend/command-api/src/middleware/error-handler.js b/backend/command-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/command-api/src/middleware/error-handler.js @@ -0,0 +1,25 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/command-api/src/middleware/idempotency.js b/backend/command-api/src/middleware/idempotency.js new file mode 100644 index 00000000..77b41a6e --- /dev/null +++ b/backend/command-api/src/middleware/idempotency.js @@ -0,0 +1,10 @@ +import { AppError } from '../lib/errors.js'; + +export function requireIdempotencyKey(req, _res, next) { + const idempotencyKey = req.get('Idempotency-Key'); + if (!idempotencyKey) { + return next(new AppError('MISSING_IDEMPOTENCY_KEY', 'Missing Idempotency-Key header', 400)); + } + req.idempotencyKey = idempotencyKey; + return next(); +} diff --git a/backend/command-api/src/middleware/request-context.js b/backend/command-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/command-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/command-api/src/routes/commands.js b/backend/command-api/src/routes/commands.js new file mode 100644 index 00000000..a16d23da --- /dev/null +++ b/backend/command-api/src/routes/commands.js @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { requireIdempotencyKey } from '../middleware/idempotency.js'; +import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js'; +import { commandBaseSchema } from '../contracts/commands/command-base.js'; + +function parseBody(body) { + const parsed = commandBaseSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +function createCommandResponse(route, requestId, idempotencyKey) { + return { + accepted: true, + route, + commandId: `${route}:${Date.now()}`, + idempotencyKey, + requestId, + }; +} + +function buildCommandHandler(policyAction, policyResource) { + return async (req, res, next) => { + try { + parseBody(req.body); + + const route = `${req.baseUrl}${req.route.path}`; + const compositeKey = buildIdempotencyKey({ + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + }); + + const existing = await readIdempotentResult(compositeKey); + if (existing) { + return res.status(existing.statusCode).json(existing.payload); + } + + const payload = createCommandResponse(route, req.requestId, req.idempotencyKey); + const persisted = await writeIdempotentResult({ + compositeKey, + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + payload, + statusCode: 200, + }); + return res.status(persisted.statusCode).json(persisted.payload); + } catch (error) { + return next(error); + } + }; +} + +export function createCommandsRouter() { + const router = Router(); + + router.post( + '/orders/create', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.create', 'order'), + buildCommandHandler('orders.create', 'order') + ); + + router.post( + '/orders/:orderId/update', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.update', 'order'), + buildCommandHandler('orders.update', 'order') + ); + + router.post( + '/orders/:orderId/cancel', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.cancel', 'order'), + buildCommandHandler('orders.cancel', 'order') + ); + + router.post( + '/shifts/:shiftId/change-status', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.change-status', 'shift'), + buildCommandHandler('shifts.change-status', 'shift') + ); + + router.post( + '/shifts/:shiftId/assign-staff', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.assign-staff', 'shift'), + buildCommandHandler('shifts.assign-staff', 'shift') + ); + + router.post( + '/shifts/:shiftId/accept', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.accept', 'shift'), + buildCommandHandler('shifts.accept', 'shift') + ); + + return router; +} diff --git a/backend/command-api/src/routes/health.js b/backend/command-api/src/routes/health.js new file mode 100644 index 00000000..90cd5690 --- /dev/null +++ b/backend/command-api/src/routes/health.js @@ -0,0 +1,15 @@ +import { Router } from 'express'; + +export const healthRouter = Router(); + +function healthHandler(req, res) { + res.status(200).json({ + ok: true, + service: 'krow-command-api', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); diff --git a/backend/command-api/src/server.js b/backend/command-api/src/server.js new file mode 100644 index 00000000..28720513 --- /dev/null +++ b/backend/command-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-command-api listening on port ${port}`); +}); diff --git a/backend/command-api/src/services/firebase-auth.js b/backend/command-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e268d5db --- /dev/null +++ b/backend/command-api/src/services/firebase-auth.js @@ -0,0 +1,13 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token) { + ensureAdminApp(); + return getAuth().verifyIdToken(token); +} diff --git a/backend/command-api/src/services/idempotency-store.js b/backend/command-api/src/services/idempotency-store.js new file mode 100644 index 00000000..8a3df3d4 --- /dev/null +++ b/backend/command-api/src/services/idempotency-store.js @@ -0,0 +1,208 @@ +import { Pool } from 'pg'; + +const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10); +const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10); + +const memoryStore = new Map(); +let adapterPromise = null; + +function shouldUseSqlStore() { + const mode = (process.env.IDEMPOTENCY_STORE || '').toLowerCase(); + if (mode === 'memory') { + return false; + } + if (mode === 'sql') { + return true; + } + return Boolean(process.env.IDEMPOTENCY_DATABASE_URL); +} + +function gcExpiredMemoryRecords(now = Date.now()) { + for (const [key, value] of memoryStore.entries()) { + if (value.expiresAt <= now) { + memoryStore.delete(key); + } + } +} + +function createMemoryAdapter() { + return { + async read(compositeKey) { + gcExpiredMemoryRecords(); + return memoryStore.get(compositeKey) || null; + }, + async write({ + compositeKey, + payload, + statusCode = 200, + }) { + const now = Date.now(); + const existing = memoryStore.get(compositeKey); + if (existing && existing.expiresAt > now) { + return existing; + } + + const record = { + payload, + statusCode, + createdAt: now, + expiresAt: now + (DEFAULT_TTL_SECONDS * 1000), + }; + memoryStore.set(compositeKey, record); + return record; + }, + }; +} + +async function createSqlAdapter() { + const connectionString = process.env.IDEMPOTENCY_DATABASE_URL; + if (!connectionString) { + throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store'); + } + + const pool = new Pool({ + connectionString, + max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10), + }); + + await pool.query(` + CREATE TABLE IF NOT EXISTS command_idempotency ( + composite_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + route TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL + ); + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at + ON command_idempotency (expires_at); + `); + + let opCount = 0; + + async function maybeCleanupExpiredRows() { + opCount += 1; + if (CLEANUP_EVERY_OPS <= 0 || opCount % CLEANUP_EVERY_OPS !== 0) { + return; + } + await pool.query('DELETE FROM command_idempotency WHERE expires_at <= NOW()'); + } + + function mapRow(row) { + return { + statusCode: row.status_code, + payload: row.response_payload, + }; + } + + return { + async read(compositeKey) { + await maybeCleanupExpiredRows(); + const result = await pool.query( + ` + SELECT status_code, response_payload + FROM command_idempotency + WHERE composite_key = $1 + AND expires_at > NOW() + `, + [compositeKey] + ); + + if (result.rowCount === 0) { + return null; + } + return mapRow(result.rows[0]); + }, + async write({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode = 200, + }) { + await maybeCleanupExpiredRows(); + + const expiresAt = new Date(Date.now() + (DEFAULT_TTL_SECONDS * 1000)); + const payloadJson = JSON.stringify(payload); + + await pool.query( + ` + INSERT INTO command_idempotency ( + composite_key, + user_id, + route, + idempotency_key, + status_code, + response_payload, + expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7) + ON CONFLICT (composite_key) DO NOTHING + `, + [compositeKey, userId, route, idempotencyKey, statusCode, payloadJson, expiresAt] + ); + + const existingResult = await pool.query( + ` + SELECT status_code, response_payload + FROM command_idempotency + WHERE composite_key = $1 + AND expires_at > NOW() + `, + [compositeKey] + ); + + if (existingResult.rowCount === 0) { + throw new Error('Idempotency write failed to persist or recover existing record'); + } + return mapRow(existingResult.rows[0]); + }, + }; +} + +async function getAdapter() { + if (!adapterPromise) { + adapterPromise = shouldUseSqlStore() + ? createSqlAdapter() + : Promise.resolve(createMemoryAdapter()); + } + return adapterPromise; +} + +export function buildIdempotencyKey({ userId, route, idempotencyKey }) { + return `${userId}:${route}:${idempotencyKey}`; +} + +export async function readIdempotentResult(compositeKey) { + const adapter = await getAdapter(); + return adapter.read(compositeKey); +} + +export async function writeIdempotentResult({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode = 200, +}) { + const adapter = await getAdapter(); + return adapter.write({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode, + }); +} + +export function __resetIdempotencyStoreForTests() { + memoryStore.clear(); + adapterPromise = null; +} diff --git a/backend/command-api/src/services/policy.js b/backend/command-api/src/services/policy.js new file mode 100644 index 00000000..44e7e371 --- /dev/null +++ b/backend/command-api/src/services/policy.js @@ -0,0 +1,5 @@ +export function can(action, resource, actor) { + void action; + void resource; + return Boolean(actor?.uid); +} diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js new file mode 100644 index 00000000..bce88d82 --- /dev/null +++ b/backend/command-api/test/app.test.js @@ -0,0 +1,54 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; +import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js'; + +process.env.AUTH_BYPASS = 'true'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('command route requires idempotency key', async () => { + const app = createApp(); + const res = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .send({ payload: {} }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY'); +}); + +test('command route is idempotent by key', async () => { + const app = createApp(); + + const first = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'abc-123') + .send({ payload: { order: 'x' } }); + + const second = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'abc-123') + .send({ payload: { order: 'x' } }); + + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal(first.body.commandId, second.body.commandId); + assert.equal(first.body.idempotencyKey, 'abc-123'); +}); diff --git a/backend/command-api/test/idempotency-store.test.js b/backend/command-api/test/idempotency-store.test.js new file mode 100644 index 00000000..cd70d03d --- /dev/null +++ b/backend/command-api/test/idempotency-store.test.js @@ -0,0 +1,56 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + __resetIdempotencyStoreForTests, + buildIdempotencyKey, + readIdempotentResult, + writeIdempotentResult, +} from '../src/services/idempotency-store.js'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +test('buildIdempotencyKey composes user route and client key', () => { + const key = buildIdempotencyKey({ + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + }); + + assert.equal(key, 'user-1:/commands/orders/create:req-abc'); +}); + +test('memory idempotency store returns existing payload for duplicate key', async () => { + const compositeKey = buildIdempotencyKey({ + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + }); + + const first = await writeIdempotentResult({ + compositeKey, + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + payload: { accepted: true, commandId: 'c-1' }, + statusCode: 200, + }); + + const second = await writeIdempotentResult({ + compositeKey, + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + payload: { accepted: true, commandId: 'c-2' }, + statusCode: 200, + }); + + const read = await readIdempotentResult(compositeKey); + + assert.equal(first.payload.commandId, 'c-1'); + assert.equal(second.payload.commandId, 'c-1'); + assert.equal(read.payload.commandId, 'c-1'); +}); diff --git a/backend/core-api/Dockerfile b/backend/core-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/core-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json new file mode 100644 index 00000000..87370c92 --- /dev/null +++ b/backend/core-api/package-lock.json @@ -0,0 +1,2962 @@ +{ + "name": "@krow/core-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/core-api", + "version": "0.1.0", + "dependencies": { + "@google-cloud/storage": "^7.16.0", + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", + "multer": "^2.0.2", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", + "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/core-api/package.json b/backend/core-api/package.json new file mode 100644 index 00000000..0e9b2f6d --- /dev/null +++ b/backend/core-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "@krow/core-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test" + }, + "dependencies": { + "@google-cloud/storage": "^7.16.0", + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", + "multer": "^2.0.2", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/core-api/src/app.js b/backend/core-api/src/app.js new file mode 100644 index 00000000..af2f1a13 --- /dev/null +++ b/backend/core-api/src/app.js @@ -0,0 +1,31 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp() { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + app.use(express.json({ limit: '2mb' })); + + app.use(healthRouter); + app.use('/core', createCoreRouter()); + app.use('/', createLegacyCoreRouter()); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/core-api/src/contracts/core/create-signed-url.js b/backend/core-api/src/contracts/core/create-signed-url.js new file mode 100644 index 00000000..726893cc --- /dev/null +++ b/backend/core-api/src/contracts/core/create-signed-url.js @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const createSignedUrlSchema = z.object({ + fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), + expiresInSeconds: z.number().int().min(60).max(3600).optional(), +}); diff --git a/backend/core-api/src/contracts/core/create-verification.js b/backend/core-api/src/contracts/core/create-verification.js new file mode 100644 index 00000000..ee03d8ec --- /dev/null +++ b/backend/core-api/src/contracts/core/create-verification.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const createVerificationSchema = z.object({ + type: z.enum(['attire', 'government_id', 'certification']), + subjectType: z.string().min(1).max(80).optional(), + subjectId: z.string().min(1).max(120).optional(), + fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), + rules: z.record(z.any()).optional().default({}), + metadata: z.record(z.any()).optional().default({}), +}); diff --git a/backend/core-api/src/contracts/core/invoke-llm.js b/backend/core-api/src/contracts/core/invoke-llm.js new file mode 100644 index 00000000..d8dcb3cb --- /dev/null +++ b/backend/core-api/src/contracts/core/invoke-llm.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const invokeLlmSchema = z.object({ + prompt: z.string().min(1).max(12000), + responseJsonSchema: z.record(z.any()), + fileUrls: z.array(z.string().url()).optional(), +}); diff --git a/backend/core-api/src/contracts/core/review-verification.js b/backend/core-api/src/contracts/core/review-verification.js new file mode 100644 index 00000000..1fb2a56c --- /dev/null +++ b/backend/core-api/src/contracts/core/review-verification.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const reviewVerificationSchema = z.object({ + decision: z.enum(['APPROVED', 'REJECTED']), + note: z.string().max(1000).optional().default(''), + reasonCode: z.string().max(100).optional().default('MANUAL_REVIEW'), +}); diff --git a/backend/core-api/src/lib/errors.js b/backend/core-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/core-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/core-api/src/middleware/auth.js b/backend/core-api/src/middleware/auth.js new file mode 100644 index 00000000..9c62c86d --- /dev/null +++ b/backend/core-api/src/middleware/auth.js @@ -0,0 +1,45 @@ +import { AppError } from '../lib/errors.js'; +import { can } from '../services/policy.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +export async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function requirePolicy(action, resource) { + return (req, _res, next) => { + if (!can(action, resource, req.actor)) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + }; +} diff --git a/backend/core-api/src/middleware/error-handler.js b/backend/core-api/src/middleware/error-handler.js new file mode 100644 index 00000000..2a08b112 --- /dev/null +++ b/backend/core-api/src/middleware/error-handler.js @@ -0,0 +1,28 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (envelope.status === 429 && envelope.body.details?.retryAfterSeconds) { + res.set('Retry-After', String(envelope.body.details.retryAfterSeconds)); + } + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/core-api/src/middleware/request-context.js b/backend/core-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/core-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js new file mode 100644 index 00000000..f9d22ef7 --- /dev/null +++ b/backend/core-api/src/routes/core.js @@ -0,0 +1,287 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { z } from 'zod'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; +import { createVerificationSchema } from '../contracts/core/create-verification.js'; +import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; +import { invokeVertexModel } from '../services/llm.js'; +import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; +import { + ensureFileExistsForActor, + generateReadSignedUrl, + uploadToGcs, + validateFileUriAccess, +} from '../services/storage.js'; +import { + createVerificationJob, + getVerificationJob, + retryVerificationJob, + reviewVerificationJob, +} from '../services/verification-jobs.js'; + +const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; +const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES), + }, +}); + +const uploadMetaSchema = z.object({ + category: z.string().max(80).optional(), + visibility: z.enum(['public', 'private']).optional(), +}); + +function mockSignedUrl(fileUri, expiresInSeconds) { + const encoded = encodeURIComponent(fileUri); + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); + return { + signedUrl: `https://storage.googleapis.com/mock-signed-url/${encoded}?expires=${expiresInSeconds}`, + expiresAt, + }; +} + +function useMockSignedUrl() { + return process.env.SIGNED_URL_MOCK !== 'false'; +} + +function useMockUpload() { + return process.env.UPLOAD_MOCK !== 'false'; +} + +function requireVerificationFileExists() { + return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false'; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +async function handleUploadFile(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + + if (!ALLOWED_FILE_TYPES.has(file.mimetype)) { + throw new AppError('INVALID_FILE_TYPE', `Unsupported file type: ${file.mimetype}`, 400); + } + + const maxFileSize = Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES); + if (file.size > maxFileSize) { + throw new AppError('FILE_TOO_LARGE', `File exceeds ${maxFileSize} bytes`, 400); + } + + const meta = parseBody(uploadMetaSchema, req.body || {}); + const visibility = meta.visibility || 'private'; + const bucket = visibility === 'public' + ? process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public' + : process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private'; + + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + const objectPath = `uploads/${req.actor.uid}/${Date.now()}_${safeName}`; + const fileUri = `gs://${bucket}/${objectPath}`; + + if (!useMockUpload()) { + await uploadToGcs({ + bucket, + objectPath, + contentType: file.mimetype, + buffer: file.buffer, + }); + } + + res.status(200).json({ + fileUri, + contentType: file.mimetype, + size: file.size, + bucket, + path: objectPath, + requestId: req.requestId, + }); + } catch (error) { + if (error?.code === 'LIMIT_FILE_SIZE') { + return next(new AppError('FILE_TOO_LARGE', 'File exceeds upload limit', 400)); + } + return next(error); + } +} + +async function handleCreateSignedUrl(req, res, next) { + try { + const payload = parseBody(createSignedUrlSchema, req.body || {}); + const expiresInSeconds = payload.expiresInSeconds || 300; + const maxSignedUrlSeconds = Number.parseInt( + process.env.MAX_SIGNED_URL_SECONDS || `${DEFAULT_MAX_SIGNED_URL_SECONDS}`, + 10 + ); + if (expiresInSeconds > maxSignedUrlSeconds) { + throw new AppError('VALIDATION_ERROR', `expiresInSeconds must be <= ${maxSignedUrlSeconds}`, 400); + } + + const signed = useMockSignedUrl() + ? (() => { + validateFileUriAccess({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + return mockSignedUrl(payload.fileUri, expiresInSeconds); + })() + : await generateReadSignedUrl({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + expiresInSeconds, + }); + + res.status(200).json({ + ...signed, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleInvokeLlm(req, res, next) { + try { + const payload = parseBody(invokeLlmSchema, req.body || {}); + const rateLimit = checkLlmRateLimit({ uid: req.actor.uid }); + if (!rateLimit.allowed) { + throw new AppError('RATE_LIMITED', 'Too many model requests. Please retry shortly.', 429, { + retryAfterSeconds: rateLimit.retryAfterSeconds, + }); + } + + const startedAt = Date.now(); + if (process.env.LLM_MOCK === 'false') { + const llmResult = await invokeVertexModel({ + prompt: payload.prompt, + responseJsonSchema: payload.responseJsonSchema, + fileUrls: payload.fileUrls, + }); + return res.status(200).json({ + result: llmResult.result, + model: llmResult.model, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } + + return res.status(200).json({ + result: { + summary: 'Mock model response. Replace with Vertex AI integration.', + inputPromptSize: payload.prompt.length, + }, + model: process.env.LLM_MODEL || 'vertexai/gemini-mock', + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleCreateVerification(req, res, next) { + try { + const payload = parseBody(createVerificationSchema, req.body || {}); + validateFileUriAccess({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + + if (requireVerificationFileExists() && !useMockUpload()) { + await ensureFileExistsForActor({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + } + + const created = createVerificationJob({ + actorUid: req.actor.uid, + payload, + }); + return res.status(202).json({ + ...created, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleGetVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const job = getVerificationJob(verificationId, req.actor.uid); + return res.status(200).json({ + ...job, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleReviewVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const payload = parseBody(reviewVerificationSchema, req.body || {}); + const updated = reviewVerificationJob(verificationId, req.actor.uid, payload); + return res.status(200).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleRetryVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const updated = retryVerificationJob(verificationId, req.actor.uid); + return res.status(202).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +export function createCoreRouter() { + const router = Router(); + + router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); + router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); + router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); + router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); + router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); + router.post('/verifications/:verificationId/retry', requireAuth, requirePolicy('core.verification.retry', 'verification'), handleRetryVerification); + + return router; +} + +export function createLegacyCoreRouter() { + const router = Router(); + + router.post('/uploadFile', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); + router.post('/createSignedUrl', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); + router.post('/invokeLLM', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + + return router; +} diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js new file mode 100644 index 00000000..9196cc83 --- /dev/null +++ b/backend/core-api/src/routes/health.js @@ -0,0 +1,15 @@ +import { Router } from 'express'; + +export const healthRouter = Router(); + +function healthHandler(req, res) { + res.status(200).json({ + ok: true, + service: 'krow-core-api', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); diff --git a/backend/core-api/src/server.js b/backend/core-api/src/server.js new file mode 100644 index 00000000..6b618206 --- /dev/null +++ b/backend/core-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-core-api listening on port ${port}`); +}); diff --git a/backend/core-api/src/services/firebase-auth.js b/backend/core-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e268d5db --- /dev/null +++ b/backend/core-api/src/services/firebase-auth.js @@ -0,0 +1,13 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token) { + ensureAdminApp(); + return getAuth().verifyIdToken(token); +} diff --git a/backend/core-api/src/services/llm-rate-limit.js b/backend/core-api/src/services/llm-rate-limit.js new file mode 100644 index 00000000..2e63f330 --- /dev/null +++ b/backend/core-api/src/services/llm-rate-limit.js @@ -0,0 +1,41 @@ +const counters = new Map(); + +function currentWindowKey(now = Date.now()) { + return Math.floor(now / 60000); +} + +function perMinuteLimit() { + return Number.parseInt(process.env.LLM_RATE_LIMIT_PER_MINUTE || '20', 10); +} + +export function checkLlmRateLimit({ uid, now = Date.now() }) { + const limit = perMinuteLimit(); + if (!Number.isFinite(limit) || limit <= 0) { + return { allowed: true, remaining: null, retryAfterSeconds: 0 }; + } + + const windowKey = currentWindowKey(now); + const record = counters.get(uid); + + if (!record || record.windowKey !== windowKey) { + counters.set(uid, { windowKey, count: 1 }); + return { allowed: true, remaining: limit - 1, retryAfterSeconds: 0 }; + } + + if (record.count >= limit) { + const retryAfterSeconds = (windowKey + 1) * 60 - Math.floor(now / 1000); + return { + allowed: false, + remaining: 0, + retryAfterSeconds: Math.max(1, retryAfterSeconds), + }; + } + + record.count += 1; + counters.set(uid, record); + return { allowed: true, remaining: limit - record.count, retryAfterSeconds: 0 }; +} + +export function __resetLlmRateLimitForTests() { + counters.clear(); +} diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js new file mode 100644 index 00000000..19c90862 --- /dev/null +++ b/backend/core-api/src/services/llm.js @@ -0,0 +1,145 @@ +import { GoogleAuth } from 'google-auth-library'; +import { AppError } from '../lib/errors.js'; + +function buildVertexConfig() { + const project = process.env.GCP_PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT; + const location = process.env.LLM_LOCATION || process.env.BACKEND_REGION || 'us-central1'; + if (!project) { + throw new AppError('MODEL_FAILED', 'GCP project is required for model invocation', 500); + } + + return { + project, + location, + }; +} + +function withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new AppError('MODEL_TIMEOUT', `Model request exceeded ${timeoutMs}ms`, 504)); + }, timeoutMs); + }), + ]); +} + +function toTextFromCandidate(candidate) { + if (!candidate?.content?.parts) { + return ''; + } + return candidate.content.parts + .map((part) => part?.text || '') + .join('') + .trim(); +} + +function withJsonSchemaInstruction(prompt, responseJsonSchema) { + const schemaText = JSON.stringify(responseJsonSchema); + return `${prompt}\n\nRespond with strict JSON only. Follow this schema exactly:\n${schemaText}`; +} + +function guessMimeTypeFromUri(fileUri) { + const path = fileUri.split('?')[0].toLowerCase(); + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.png')) return 'image/png'; + if (path.endsWith('.pdf')) return 'application/pdf'; + return 'application/octet-stream'; +} + +function buildMultimodalParts(prompt, fileUris = []) { + const parts = [{ text: prompt }]; + for (const fileUri of fileUris) { + parts.push({ + fileData: { + fileUri, + mimeType: guessMimeTypeFromUri(fileUri), + }, + }); + } + return parts; +} + +async function callVertexJsonModel({ model, timeoutMs, parts }) { + const { project, location } = buildVertexConfig(); + const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + let response; + try { + const client = await auth.getClient(); + response = await withTimeout( + client.request({ + url, + method: 'POST', + data: { + contents: [{ role: 'user', parts }], + generationConfig: { + temperature: 0.2, + responseMimeType: 'application/json', + }, + }, + }), + timeoutMs + ); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('MODEL_FAILED', 'Model invocation failed', 502); + } + + const text = toTextFromCandidate(response?.data?.candidates?.[0]); + if (!text) { + throw new AppError('MODEL_FAILED', 'Model returned empty response', 502); + } + + try { + return { + model, + result: JSON.parse(text), + }; + } catch { + return { + model, + result: { + raw: text, + }, + }; + } +} + +export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { + const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; + return callVertexJsonModel({ + model, + timeoutMs, + parts: [{ text: `${promptWithSchema}${fileContext}` }], + }); +} + +export async function invokeVertexMultimodalModel({ + prompt, + responseJsonSchema, + fileUris = [], + model, + timeoutMs, +}) { + const resolvedModel = model || process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const resolvedTimeoutMs = Number.parseInt( + `${timeoutMs || process.env.LLM_TIMEOUT_MS || '20000'}`, + 10 + ); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + return callVertexJsonModel({ + model: resolvedModel, + timeoutMs: resolvedTimeoutMs, + parts: buildMultimodalParts(promptWithSchema, fileUris), + }); +} diff --git a/backend/core-api/src/services/policy.js b/backend/core-api/src/services/policy.js new file mode 100644 index 00000000..44e7e371 --- /dev/null +++ b/backend/core-api/src/services/policy.js @@ -0,0 +1,5 @@ +export function can(action, resource, actor) { + void action; + void resource; + return Boolean(actor?.uid); +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js new file mode 100644 index 00000000..3dcfc2d7 --- /dev/null +++ b/backend/core-api/src/services/storage.js @@ -0,0 +1,83 @@ +import { Storage } from '@google-cloud/storage'; +import { AppError } from '../lib/errors.js'; + +const storage = new Storage(); + +export function parseGsUri(fileUri) { + if (!fileUri.startsWith('gs://')) { + throw new AppError('VALIDATION_ERROR', 'fileUri must start with gs://', 400); + } + + const raw = fileUri.replace('gs://', ''); + const slashIndex = raw.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= raw.length - 1) { + throw new AppError('VALIDATION_ERROR', 'fileUri must include bucket and object path', 400); + } + + return { + bucket: raw.slice(0, slashIndex), + path: raw.slice(slashIndex + 1), + }; +} + +function allowedBuckets() { + return new Set([ + process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public', + process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private', + ]); +} + +export function validateFileUriAccess({ fileUri, actorUid }) { + const { bucket, path } = parseGsUri(fileUri); + if (!allowedBuckets().has(bucket)) { + throw new AppError('FORBIDDEN', `Bucket not allowed for signing: ${bucket}`, 403); + } + + const ownedPrefix = `uploads/${actorUid}/`; + if (!path.startsWith(ownedPrefix)) { + throw new AppError('FORBIDDEN', 'Cannot sign URL for another user path', 403); + } + + return { bucket, path }; +} + +export async function uploadToGcs({ bucket, objectPath, contentType, buffer }) { + const file = storage.bucket(bucket).file(objectPath); + await file.save(buffer, { + resumable: false, + contentType, + metadata: { + cacheControl: 'private, max-age=0', + }, + }); +} + +export async function generateReadSignedUrl({ fileUri, actorUid, expiresInSeconds }) { + const { bucket, path } = validateFileUriAccess({ fileUri, actorUid }); + const file = storage.bucket(bucket).file(path); + const [exists] = await file.exists(); + if (!exists) { + throw new AppError('NOT_FOUND', 'File not found for signed URL', 404, { fileUri }); + } + + const expiresAtMs = Date.now() + expiresInSeconds * 1000; + const [signedUrl] = await file.getSignedUrl({ + version: 'v4', + action: 'read', + expires: expiresAtMs, + }); + + return { + signedUrl, + expiresAt: new Date(expiresAtMs).toISOString(), + }; +} + +export async function ensureFileExistsForActor({ fileUri, actorUid }) { + const { bucket, path } = validateFileUriAccess({ fileUri, actorUid }); + const file = storage.bucket(bucket).file(path); + const [exists] = await file.exists(); + if (!exists) { + throw new AppError('NOT_FOUND', 'Evidence file not found', 404, { fileUri }); + } +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js new file mode 100644 index 00000000..5ffe44bd --- /dev/null +++ b/backend/core-api/src/services/verification-jobs.js @@ -0,0 +1,510 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { invokeVertexMultimodalModel } from './llm.js'; + +const jobs = new Map(); + +export const VerificationStatus = Object.freeze({ + PENDING: 'PENDING', + PROCESSING: 'PROCESSING', + AUTO_PASS: 'AUTO_PASS', + AUTO_FAIL: 'AUTO_FAIL', + NEEDS_REVIEW: 'NEEDS_REVIEW', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + ERROR: 'ERROR', +}); + +const MACHINE_TERMINAL_STATUSES = new Set([ + VerificationStatus.AUTO_PASS, + VerificationStatus.AUTO_FAIL, + VerificationStatus.NEEDS_REVIEW, + VerificationStatus.ERROR, +]); + +const HUMAN_TERMINAL_STATUSES = new Set([ + VerificationStatus.APPROVED, + VerificationStatus.REJECTED, +]); + +function nowIso() { + return new Date().toISOString(); +} + +function accessMode() { + return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; +} + +function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) { + return { + id: crypto.randomUUID(), + fromStatus, + toStatus, + actorType, + actorId, + details, + createdAt: nowIso(), + }; +} + +function toPublicJob(job) { + return { + verificationId: job.id, + type: job.type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + status: job.status, + confidence: job.confidence, + reasons: job.reasons, + extracted: job.extracted, + provider: job.provider, + review: job.review, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }; +} + +function assertAccess(job, actorUid) { + if (accessMode() === 'authenticated') { + return; + } + if (job.ownerUid !== actorUid) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + } +} + +function requireJob(id) { + const job = jobs.get(id); + if (!job) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id }); + } + return job; +} + +function normalizeMachineStatus(status) { + if ( + status === VerificationStatus.AUTO_PASS + || status === VerificationStatus.AUTO_FAIL + || status === VerificationStatus.NEEDS_REVIEW + ) { + return status; + } + return VerificationStatus.NEEDS_REVIEW; +} + +function clampConfidence(value, fallback = 0.5) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < 0) return 0; + if (parsed > 1) return 1; + return parsed; +} + +function asReasonList(reasons, fallback) { + if (Array.isArray(reasons) && reasons.length > 0) { + return reasons.map((item) => `${item}`); + } + return [fallback]; +} + +function providerTimeoutMs() { + return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); +} + +function attireModel() { + return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; +} + +async function runAttireChecks(job) { + if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { + return { + status: VerificationStatus.AUTO_PASS, + confidence: 0.8, + reasons: ['Auto-pass mode enabled for attire in dev'], + extracted: { + expected: job.rules, + }, + provider: { + name: 'attire-auto-pass', + reference: null, + }, + }; + } + + const attireProvider = process.env.VERIFICATION_ATTIRE_PROVIDER || 'vertex'; + if (attireProvider !== 'vertex') { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.45, + reasons: [`Attire provider '${attireProvider}' is not supported`], + extracted: { + expected: job.rules, + }, + provider: { + name: attireProvider, + reference: null, + }, + }; + } + + try { + const prompt = [ + 'You are validating worker attire evidence.', + `Rules: ${JSON.stringify(job.rules || {})}`, + 'Return AUTO_PASS only when the image clearly matches required attire.', + 'Return AUTO_FAIL when the image clearly violates required attire.', + 'Return NEEDS_REVIEW when uncertain.', + ].join('\n'); + + const schema = { + type: 'object', + properties: { + status: { type: 'string' }, + confidence: { type: 'number' }, + reasons: { + type: 'array', + items: { type: 'string' }, + }, + extracted: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['status', 'confidence', 'reasons'], + }; + + const modelOutput = await invokeVertexMultimodalModel({ + prompt, + responseJsonSchema: schema, + fileUris: [job.fileUri], + model: attireModel(), + timeoutMs: providerTimeoutMs(), + }); + + const result = modelOutput?.result || {}; + return { + status: normalizeMachineStatus(result.status), + confidence: clampConfidence(result.confidence, 0.6), + reasons: asReasonList(result.reasons, 'Attire check completed'), + extracted: result.extracted || {}, + provider: { + name: 'vertex-attire', + reference: modelOutput?.model || attireModel(), + }, + }; + } catch (error) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: ['Automatic attire check unavailable, manual review required'], + extracted: {}, + provider: { + name: 'vertex-attire', + reference: `error:${error?.code || 'unknown'}`, + }, + }; + } +} + +function getProviderConfig(type) { + if (type === 'government_id') { + return { + name: 'government-id-provider', + url: process.env.VERIFICATION_GOV_ID_PROVIDER_URL, + token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, + }; + } + return { + name: 'certification-provider', + url: process.env.VERIFICATION_CERT_PROVIDER_URL, + token: process.env.VERIFICATION_CERT_PROVIDER_TOKEN, + }; +} + +async function runThirdPartyChecks(job, type) { + const provider = getProviderConfig(type); + if (!provider.url) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.4, + reasons: [`${provider.name} is not configured`], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), providerTimeoutMs()); + + try { + const response = await fetch(provider.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(provider.token ? { Authorization: `Bearer ${provider.token}` } : {}), + }, + body: JSON.stringify({ + type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + rules: job.rules, + metadata: job.metadata, + }), + signal: controller.signal, + }); + + const bodyText = await response.text(); + let body = {}; + try { + body = bodyText ? JSON.parse(bodyText) : {}; + } catch { + body = {}; + } + + if (!response.ok) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: [`${provider.name} returned ${response.status}`], + extracted: {}, + provider: { + name: provider.name, + reference: body?.reference || null, + }, + }; + } + + return { + status: normalizeMachineStatus(body.status), + confidence: clampConfidence(body.confidence, 0.6), + reasons: asReasonList(body.reasons, `${provider.name} completed check`), + extracted: body.extracted || {}, + provider: { + name: provider.name, + reference: body.reference || null, + }, + }; + } catch (error) { + const isAbort = error?.name === 'AbortError'; + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.3, + reasons: [ + isAbort + ? `${provider.name} timeout, manual review required` + : `${provider.name} unavailable, manual review required`, + ], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } finally { + clearTimeout(timeout); + } +} + +async function runMachineChecks(job) { + if (job.type === 'attire') { + return runAttireChecks(job); + } + + if (job.type === 'government_id') { + return runThirdPartyChecks(job, 'government_id'); + } + + return runThirdPartyChecks(job, 'certification'); +} + +async function processVerificationJob(id) { + const job = requireJob(id); + if (job.status !== VerificationStatus.PENDING) { + return; + } + + const beforeProcessing = job.status; + job.status = VerificationStatus.PROCESSING; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus: beforeProcessing, + toStatus: VerificationStatus.PROCESSING, + actorType: 'system', + actorId: 'verification-worker', + }) + ); + + try { + const outcome = await runMachineChecks(job); + if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) { + throw new Error(`Invalid machine outcome status: ${outcome.status}`); + } + const fromStatus = job.status; + job.status = outcome.status; + job.confidence = outcome.confidence; + job.reasons = outcome.reasons; + job.extracted = outcome.extracted; + job.provider = outcome.provider; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'system', + actorId: 'verification-worker', + details: { + confidence: job.confidence, + reasons: job.reasons, + provider: job.provider, + }, + }) + ); + } catch (error) { + const fromStatus = job.status; + job.status = VerificationStatus.ERROR; + job.confidence = null; + job.reasons = [error?.message || 'Verification processing failed']; + job.extracted = {}; + job.provider = { + name: 'verification-worker', + reference: null, + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.ERROR, + actorType: 'system', + actorId: 'verification-worker', + details: { + error: error?.message || 'Verification processing failed', + }, + }) + ); + } +} + +function queueVerificationProcessing(id) { + setTimeout(() => { + processVerificationJob(id).catch(() => {}); + }, 0); +} + +export function createVerificationJob({ actorUid, payload }) { + const now = nowIso(); + const id = `ver_${crypto.randomUUID()}`; + const job = { + id, + type: payload.type, + subjectType: payload.subjectType || null, + subjectId: payload.subjectId || null, + ownerUid: actorUid, + fileUri: payload.fileUri, + rules: payload.rules || {}, + metadata: payload.metadata || {}, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider: null, + review: null, + createdAt: now, + updatedAt: now, + events: [ + eventRecord({ + fromStatus: null, + toStatus: VerificationStatus.PENDING, + actorType: 'system', + actorId: actorUid, + }), + ], + }; + jobs.set(id, job); + queueVerificationProcessing(id); + return toPublicJob(job); +} + +export function getVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); +} + +export function reviewVerificationJob(verificationId, actorUid, review) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const fromStatus = job.status; + job.status = review.decision; + job.review = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: nowIso(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'reviewer', + actorId: actorUid, + details: { + reasonCode: job.review.reasonCode, + }, + }) + ); + + return toPublicJob(job); +} + +export function retryVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } + + const fromStatus = job.status; + job.status = VerificationStatus.PENDING; + job.confidence = null; + job.reasons = []; + job.extracted = {}; + job.provider = null; + job.review = null; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.PENDING, + actorType: 'reviewer', + actorId: actorUid, + details: { + retried: true, + }, + }) + ); + queueVerificationProcessing(verificationId); + return toPublicJob(job); +} + +export function __resetVerificationJobsForTests() { + jobs.clear(); +} diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js new file mode 100644 index 00000000..e12f9005 --- /dev/null +++ b/backend/core-api/test/app.test.js @@ -0,0 +1,246 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; +import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; +import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; + +beforeEach(() => { + process.env.AUTH_BYPASS = 'true'; + process.env.LLM_MOCK = 'true'; + process.env.SIGNED_URL_MOCK = 'true'; + process.env.UPLOAD_MOCK = 'true'; + process.env.MAX_SIGNED_URL_SECONDS = '900'; + process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; + process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; + process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; + process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; + __resetLlmRateLimitForTests(); + __resetVerificationJobsForTests(); +}); + +async function waitForMachineStatus(app, verificationId, maxAttempts = 30) { + let last; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + last = await request(app) + .get(`/core/verifications/${verificationId}`) + .set('Authorization', 'Bearer test-token'); + if ( + last.body?.status === 'AUTO_PASS' + || last.body?.status === 'AUTO_FAIL' + || last.body?.status === 'NEEDS_REVIEW' + || last.body?.status === 'ERROR' + ) { + return last; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return last; +} + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(typeof res.body.requestId, 'string'); + assert.equal(typeof res.headers['x-request-id'], 'string'); +}); + +test('POST /core/create-signed-url requires auth', async () => { + process.env.AUTH_BYPASS = 'false'; + const app = createApp(); + const res = await request(app).post('/core/create-signed-url').send({ + fileUri: 'gs://krow-workforce-dev-private/foo.pdf', + }); + + assert.equal(res.status, 401); + assert.equal(res.body.code, 'UNAUTHENTICATED'); + process.env.AUTH_BYPASS = 'true'; +}); + +test('POST /core/create-signed-url returns signed URL', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/foo.pdf', + expiresInSeconds: 300, + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.signedUrl, 'string'); + assert.equal(typeof res.body.expiresAt, 'string'); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('POST /core/create-signed-url rejects non-owned path', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/foo.pdf', + expiresInSeconds: 300, + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/create-signed-url enforces expiry cap', async () => { + process.env.MAX_SIGNED_URL_SECONDS = '300'; + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/foo.pdf', + expiresInSeconds: 301, + }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + +test('POST /invokeLLM legacy alias works', async () => { + const app = createApp(); + const res = await request(app) + .post('/invokeLLM') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.result, 'object'); + assert.equal(typeof res.body.model, 'string'); +}); + +test('POST /core/invoke-llm enforces per-user rate limit', async () => { + process.env.LLM_RATE_LIMIT_PER_MINUTE = '1'; + const app = createApp(); + + const first = await request(app) + .post('/core/invoke-llm') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + const second = await request(app) + .post('/core/invoke-llm') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello again', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + assert.equal(first.status, 200); + assert.equal(second.status, 429); + assert.equal(second.body.code, 'RATE_LIMITED'); + assert.equal(typeof second.headers['retry-after'], 'string'); +}); + +test('POST /core/verifications creates async job and GET returns status', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/attire.jpg', + rules: { attireType: 'shoes', expectedColor: 'black' }, + }); + + assert.equal(created.status, 202); + assert.equal(created.body.type, 'attire'); + assert.equal(created.body.status, 'PENDING'); + assert.equal(typeof created.body.verificationId, 'string'); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + assert.equal(status.body.verificationId, created.body.verificationId); + assert.equal(status.body.type, 'attire'); + assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); +}); + +test('POST /core/verifications rejects file paths not owned by actor', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/not-allowed.jpg', + rules: { attireType: 'shoes' }, + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/verifications/:id/review finalizes verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'certification', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/cert.pdf', + rules: { certType: 'food_safety' }, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const reviewed = await request(app) + .post(`/core/verifications/${created.body.verificationId}/review`) + .set('Authorization', 'Bearer test-token') + .send({ + decision: 'APPROVED', + note: 'Looks good', + reasonCode: 'MANUAL_REVIEW', + }); + + assert.equal(reviewed.status, 200); + assert.equal(reviewed.body.status, 'APPROVED'); + assert.equal(reviewed.body.review.decision, 'APPROVED'); +}); + +test('POST /core/verifications/:id/retry requeues verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'government_id', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/id-front.jpg', + rules: {}, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const retried = await request(app) + .post(`/core/verifications/${created.body.verificationId}/retry`) + .set('Authorization', 'Bearer test-token') + .send({}); + + assert.equal(retried.status, 202); + assert.equal(retried.body.status, 'PENDING'); +}); diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index b7cb6c46..47ce99fe 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,6 +15,7 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! + $hubManagerId: UUID $recurringDays: [String!] $permanentStartDate: Timestamp $permanentDays: [String!] @@ -40,6 +41,7 @@ mutation createOrder( shifts: $shifts requested: $requested teamHubId: $teamHubId + hubManagerId: $hubManagerId recurringDays: $recurringDays permanentDays: $permanentDays notes: $notes diff --git a/backend/dataconnect/connector/staffAttire/mutations.gql b/backend/dataconnect/connector/staffAttire/mutations.gql index 25184389..72fa489b 100644 --- a/backend/dataconnect/connector/staffAttire/mutations.gql +++ b/backend/dataconnect/connector/staffAttire/mutations.gql @@ -3,6 +3,7 @@ mutation upsertStaffAttire( $attireOptionId: UUID! $verificationPhotoUrl: String $verificationId: String + $verificationStatus: AttireVerificationStatus ) @auth(level: USER) { staffAttire_upsert( data: { @@ -10,7 +11,7 @@ mutation upsertStaffAttire( attireOptionId: $attireOptionId verificationPhotoUrl: $verificationPhotoUrl verificationId: $verificationId - verificationStatus: PENDING + verificationStatus: $verificationStatus } ) } diff --git a/backend/dataconnect/schema/staffAttire.gql b/backend/dataconnect/schema/staffAttire.gql index e61e8f9b..c3f0e213 100644 --- a/backend/dataconnect/schema/staffAttire.gql +++ b/backend/dataconnect/schema/staffAttire.gql @@ -1,7 +1,12 @@ enum AttireVerificationStatus { PENDING - FAILED - SUCCESS + PROCESSING + AUTO_PASS + AUTO_FAIL + NEEDS_REVIEW + APPROVED + REJECTED + ERROR } type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"]) { diff --git a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md index fd1f30e1..b3f860ab 100644 --- a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md +++ b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md @@ -1,266 +1,365 @@ # KROW Workforce API Contracts -This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details. +This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**. --- ## Staff Application -### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info) +### Authentication / Onboarding Pages +*(Pages: get_started_page.dart, intro_page.dart, phone_verification_page.dart, profile_setup_page.dart)* + #### Setup / User Validation API | Field | Description | |---|---| -| **Endpoint name** | `/getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). | +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if the user is STAFF). | | **Operation** | Query | | **Inputs** | `id: UUID!` (Firebase UID) | | **Outputs** | `User { id, email, phone, role }` | -| **Notes** | Required after OTP verification to route users. | +| **Notes** | Required after OTP verification to route users appropriately. | #### Create Default User API | Field | Description | |---|---| -| **Endpoint name** | `/createUser` | +| **Conceptual Endpoint** | `POST /users` | +| **Data Connect OP** | `createUser` | | **Purpose** | Inserts a base user record into the system during initial signup. | | **Operation** | Mutation | | **Inputs** | `id: UUID!`, `role: UserBaseRole` | | **Outputs** | `id` of newly created User | -| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't physically exist in the database. | #### Get Staff Profile API | Field | Description | |---|---| -| **Endpoint name** | `/getStaffByUserId` | +| **Conceptual Endpoint** | `GET /staff/user/{userId}` | +| **Data Connect OP** | `getStaffByUserId` | | **Purpose** | Finds the specific Staff record associated with the base user ID. | | **Operation** | Query | | **Inputs** | `userId: UUID!` | | **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | -| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. | +| **Notes** | Needed to verify if a complete staff profile exists before allowing navigation to the main app dashboard. | #### Update Staff Profile API | Field | Description | |---|---| -| **Endpoint name** | `/updateStaff` | +| **Conceptual Endpoint** | `PUT /staff/{id}` | +| **Data Connect OP** | `updateStaff` | | **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | | **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `address`, etc. | | **Outputs** | `id` | -| **Notes** | Called incrementally during profile setup wizard. | +| **Notes** | Called incrementally during the profile setup wizard as the user fills out step-by-step information. | + +### Home Page & Benefits Overview +*(Pages: worker_home_page.dart, benefits_overview_page.dart)* -### Home Page (worker_home_page.dart) & Benefits Overview #### Load Today/Tomorrow Shifts | Field | Description | |---|---| -| **Endpoint name** | `/getApplicationsByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/applications` | +| **Data Connect OP** | `getApplicationsByStaffId` | | **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | | **Operation** | Query | | **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | | **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | -| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to successfully display "Today's" and "Tomorrow's" shifts. | #### List Recommended Shifts | Field | Description | |---|---| -| **Endpoint name** | `/listShifts` | +| **Conceptual Endpoint** | `GET /shifts/recommended` | +| **Data Connect OP** | `listShifts` | | **Purpose** | Fetches open shifts that are available for the staff to apply to. | | **Operation** | Query | -| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. | +| **Inputs** | None directly mapped on load, but fetches available items logically. | | **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | -| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on an active backend `$status: OPEN` parameter. | #### Benefits Summary API | Field | Description | |---|---| -| **Endpoint name** | `/listBenefitsDataByStaffId` | -| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. | +| **Conceptual Endpoint** | `GET /staff/{staffId}/benefits` | +| **Data Connect OP** | `listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display gracefully on the home screen. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | -| **Notes** | Calculates `usedHours = total - current`. | +| **Notes** | Used by `benefits_overview_page.dart`. Derives available metrics via `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages +*(Pages: shifts_page.dart, shift_details_page.dart)* -### Find Shifts / Shift Details Pages (shifts_page.dart) #### List Available Shifts Filtered | Field | Description | |---|---| -| **Endpoint name** | `/filterShifts` | +| **Conceptual Endpoint** | `GET /shifts` | +| **Data Connect OP** | `filterShifts` | | **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | | **Operation** | Query | | **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | | **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | -| **Notes** | - | +| **Notes** | Main driver for discovering available work. | #### Get Shift Details | Field | Description | |---|---| -| **Endpoint name** | `/getShiftById` | -| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. | +| **Conceptual Endpoint** | `GET /shifts/{id}` | +| **Data Connect OP** | `getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform requirements and managers. | | **Operation** | Query | | **Inputs** | `id: UUID!` | | **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | -| **Notes** | - | +| **Notes** | Invoked when users click into a full `shift_details_page.dart`. | #### Apply To Shift | Field | Description | |---|---| -| **Endpoint name** | `/createApplication` | -| **Purpose** | Worker submits an intent to take an open shift. | +| **Conceptual Endpoint** | `POST /applications` | +| **Data Connect OP** | `createApplication` | +| **Purpose** | Worker submits an intent to take an open shift (creates an application record). | | **Operation** | Mutation | -| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` | -| **Outputs** | `Application ID` | -| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. | +| **Inputs** | `shiftId: UUID!`, `staffId: UUID!`, `roleId: UUID!`, `status: ApplicationStatus!` (e.g. `PENDING` or `CONFIRMED`), `origin: ApplicationOrigin!` (e.g. `STAFF`); optional: `checkInTime`, `checkOutTime` | +| **Outputs** | `application_insert.id` (Application ID) | +| **Notes** | The app uses `status: CONFIRMED` and `origin: STAFF` when claiming; backend also supports `PENDING` for admin review flows. After creation, shift-role assigned count and shift filled count are updated. | + +### Availability Page +*(Pages: availability_page.dart)* -### Availability Page (availability_page.dart) #### Get Default Availability | Field | Description | |---|---| -| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/availabilities` | +| **Data Connect OP** | `listStaffAvailabilitiesByStaffId` | | **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | -| **Notes** | - | +| **Notes** | Bound to Monday through Sunday configuration. | #### Update Availability | Field | Description | |---|---| -| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) | +| **Conceptual Endpoint** | `PUT /staff/availabilities/{id}` | +| **Data Connect OP** | `updateStaffAvailability` (or `createStaffAvailability` for new entries) | | **Purpose** | Upserts availability preferences. | | **Operation** | Mutation | | **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | | **Outputs** | `id` | | **Notes** | Called individually per day edited. | -### Payments Page (payments_page.dart) +### Payments Page +*(Pages: payments_page.dart, early_pay_page.dart)* + #### Get Recent Payments | Field | Description | |---|---| -| **Endpoint name** | `/listRecentPaymentsByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/payments` | +| **Data Connect OP** | `listRecentPaymentsByStaffId` | | **Purpose** | Loads the history of earnings and timesheets completed by the staff. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `Payments { amount, processDate, shiftId, status }` | -| **Notes** | Displays historical metrics under Earnings tab. | +| **Notes** | Displays historical metrics under the comprehensive Earnings tab. | + +### Compliance / Profiles +*(Pages: certificates_page.dart, documents_page.dart, tax_forms_page.dart, form_i9_page.dart, form_w4_page.dart)* -### Compliance / Profiles (Agreements, W4, I9, Documents) #### Get Tax Forms | Field | Description | |---|---| -| **Endpoint name** | `/getTaxFormsByStaffId` | -| **Purpose** | Check the filing status of I9 and W4 forms. | +| **Conceptual Endpoint** | `GET /staff/{staffId}/tax-forms` | +| **Data Connect OP** | `getTaxFormsByStaffId` | +| **Purpose** | Check the filing status and detailed inputs of I9 and W4 forms. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | -| **Notes** | Required for staff to be eligible for shifts. | +| **Notes** | Crucial requirement for staff to be eligible to apply for highly regulated shifts. | #### Update Tax Forms | Field | Description | |---|---| -| **Endpoint name** | `/updateTaxForm` | -| **Purpose** | Submits state and filing for the given tax form type. | +| **Conceptual Endpoint** | `PUT /tax-forms/{id}` | +| **Data Connect OP** | `updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type (W4/I9). | | **Operation** | Mutation | | **Inputs** | `id`, `dataPoints...` | | **Outputs** | `id` | -| **Notes** | Updates compliance state. | +| **Notes** | Modifies the core compliance state variables directly. | --- ## Client Application -### Authentication / Intro (Sign In, Get Started) +### Authentication / Intro +*(Pages: client_sign_in_page.dart, client_get_started_page.dart)* + #### Client User Validation API | Field | Description | |---|---| -| **Endpoint name** | `/getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). | +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (ensuring user is BUSINESS). | | **Operation** | Query | | **Inputs** | `id: UUID!` (Firebase UID) | | **Outputs** | `User { id, email, phone, userRole }` | -| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. | +| **Notes** | Validates against conditional statements checking `userRole == BUSINESS` or `BOTH`. | -#### Get Business Profile API +#### Get Businesses By User API | Field | Description | |---|---| -| **Endpoint name** | `/getBusinessByUserId` | +| **Conceptual Endpoint** | `GET /business/user/{userId}` | +| **Data Connect OP** | `getBusinessesByUserId` | | **Purpose** | Maps the authenticated user to their client business context. | | **Operation** | Query | -| **Inputs** | `userId: UUID!` | -| **Outputs** | `Business { id, businessName, email, contactName }` | -| **Notes** | Used to set the working scopes (Business ID) across the entire app. | +| **Inputs** | `userId: String!` | +| **Outputs** | `Businesses { id, businessName, email, contactName }` | +| **Notes** | Dictates the working scopes (Business ID) across the entire application lifecycle and binds the user. | -### Hubs Page (client_hubs_page.dart, edit_hub.dart) -#### List Hubs +### Hubs Page +*(Pages: client_hubs_page.dart, edit_hub_page.dart, hub_details_page.dart)* + +#### List Hubs by Team | Field | Description | |---|---| -| **Endpoint name** | `/listTeamHubsByBusinessId` | -| **Purpose** | Fetches the primary working sites (Hubs) for a client. | +| **Conceptual Endpoint** | `GET /teams/{teamId}/hubs` | +| **Data Connect OP** | `getTeamHubsByTeamId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client context by using Team mapping. | | **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` | -| **Notes** | - | +| **Inputs** | `teamId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, managerName, isActive }` | +| **Notes** | `teamId` is derived first from `getTeamsByOwnerId(ownerId: businessId)`. | -#### Update / Delete Hub +#### Create / Update / Delete Hub | Field | Description | |---|---| -| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` | -| **Purpose** | Edits or archives a Hub location. | +| **Conceptual Endpoint** | `POST /team-hubs` / `PUT /team-hubs/{id}` / `DELETE /team-hubs/{id}` | +| **Data Connect OP** | `createTeamHub` / `updateTeamHub` / `deleteTeamHub` | +| **Purpose** | Provisions, Edits details directly, or Removes a Team Hub location. | | **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) | +| **Inputs** | `id: UUID!`, optionally `hubName`, `address`, etc. | | **Outputs** | `id` | -| **Notes** | - | +| **Notes** | Fired from `edit_hub_page.dart` mutations. | + +### Orders Page +*(Pages: create_order_page.dart, view_orders_page.dart, recurring_order_page.dart)* -### Orders Page (create_order, view_orders) #### Create Order | Field | Description | |---|---| -| **Endpoint name** | `/createOrder` | -| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). | +| **Conceptual Endpoint** | `POST /orders` | +| **Data Connect OP** | `createOrder` | +| **Purpose** | Submits a new request for temporary staff requirements. | | **Operation** | Mutation | | **Inputs** | `businessId`, `eventName`, `orderType`, `status` | | **Outputs** | `id` (Order ID) | -| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. | +| **Notes** | This explicitly invokes an order pipeline, meaning Shift instances are subsequently created through secondary mutations triggered after order instantiation. | #### List Orders | Field | Description | |---|---| -| **Endpoint name** | `/getOrdersByBusinessId` | +| **Conceptual Endpoint** | `GET /business/{businessId}/orders` | +| **Data Connect OP** | `listOrdersByBusinessId` | | **Purpose** | Retrieves all ongoing and past staff requests from the client. | | **Operation** | Query | | **Inputs** | `businessId: UUID!` | -| **Outputs** | `Orders { id, eventName, shiftCount, status }` | -| **Notes** | - | +| **Outputs** | `Orders { id, eventName }` | +| **Notes** | Populates the `view_orders_page.dart`. | + +### Billing Pages +*(Pages: billing_page.dart, pending_invoices_page.dart, completion_review_page.dart)* -### Billing Pages (billing_page.dart, pending_invoices) #### List Invoices | Field | Description | |---|---| -| **Endpoint name** | `/listInvoicesByBusinessId` | -| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. | +| **Conceptual Endpoint** | `GET /business/{businessId}/invoices` | +| **Data Connect OP** | `listInvoicesByBusinessId` | +| **Purpose** | Fetches all invoices bound directly to the active business context (mapped directly in Firebase Schema). | | **Operation** | Query | | **Inputs** | `businessId: UUID!` | -| **Outputs** | `Invoices { id, amountDue, issueDate, status }` | -| **Notes** | Used across all Billing view tabs. | +| **Outputs** | `Invoices { id, amount, issueDate, status }` | +| **Notes** | Used massively across all Billing view tabs. | -#### Mark Invoice +#### Mark / Dispute Invoice | Field | Description | |---|---| -| **Endpoint name** | `/updateInvoice` | -| **Purpose** | Marks an invoice as disputed or pays it (changes status). | +| **Conceptual Endpoint** | `PUT /invoices/{id}` | +| **Data Connect OP** | `updateInvoice` | +| **Purpose** | Actively marks an invoice as disputed or pays it directly (altering status). | | **Operation** | Mutation | | **Inputs** | `id: UUID!`, `status: InvoiceStatus` | | **Outputs** | `id` | -| **Notes** | Disputing usually involves setting a memo or flag. | +| **Notes** | Disputing usually involves setting a `disputeReason` flag state dynamically via builder pattern. | + +### Reports Page +*(Pages: reports_page.dart, coverage_report_page.dart, performance_report_page.dart)* -### Reports Page (reports_page.dart) #### Get Coverage Stats | Field | Description | |---|---| -| **Endpoint name** | `/getCoverageStatsByBusiness` | -| **Purpose** | Provides data on fulfillments rates vs actual requests. | +| **Conceptual Endpoint** | `GET /business/{businessId}/coverage` | +| **Data Connect OP** | `listShiftsForCoverage` | +| **Purpose** | Provides data on Shifts grouped by Date for fulfillment calculations. | | **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` | -| **Notes** | Driven mostly by aggregated backend views. | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, filled, status }` | +| **Notes** | The frontend aggregates the raw backend rows to compose Coverage percentage natively. | + +#### Get Daily Ops Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/dailyops` | +| **Data Connect OP** | `listShiftsForDailyOpsByBusiness` | +| **Purpose** | Supplies current day operations and shift tracking progress. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `date: Timestamp!` | +| **Outputs** | `Shifts { id, title, location, workersNeeded, filled }` | +| **Notes** | - | + +#### Get Forecast Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/forecast` | +| **Data Connect OP** | `listShiftsForForecastByBusiness` | +| **Purpose** | Retrieves scheduled future shifts to calculate financial run-rates. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, hours, cost }` | +| **Notes** | The App maps hours `x` cost to deliver Financial Dashboards. | + +#### Get Performance KPIs +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/performance` | +| **Data Connect OP** | `listShiftsForPerformanceByBusiness` | +| **Purpose** | Fetches historical data allowing time-to-fill and completion-rate calculations. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, workersNeeded, filled, createdAt, filledAt }` | +| **Notes** | Data Connect exposes timestamps so the App calculates `avgFillTimeHours`. | + +#### Get No-Show Metrics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/noshows` | +| **Data Connect OP** | `listShiftsForNoShowRangeByBusiness` | +| **Purpose** | Retrieves shifts where workers historically ghosted the platform. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date }` | +| **Notes** | Accompanies `listApplicationsForNoShowRange` cascading querying to generate full report. | + +#### Get Spend Analytics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/spend` | +| **Data Connect OP** | `listInvoicesForSpendByBusiness` | +| **Purpose** | Detailed invoice aggregates for Spend metrics filtering. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Invoices { id, issueDate, dueDate, amount, status }` | +| **Notes** | Used explicitly under the "Spend Report" graphings. | --- - -*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.* diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md new file mode 100644 index 00000000..516ebf38 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -0,0 +1,232 @@ +# M4 API Catalog (Core Only) + +Status: Active +Date: 2026-02-24 +Owner: Technical Lead +Environment: dev + +## Frontend source of truth +Use this file and `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` for core endpoint consumption. + +## Related next-slice contract +Verification pipeline design (attire, government ID, certification): +- `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md` + +## 1) Scope and purpose +This catalog defines the currently implemented core backend contract for M4. + +## 2) Global API rules +1. Route group in scope: `/core/*`. +2. Compatibility aliases in scope: +- `POST /uploadFile` -> `POST /core/upload-file` +- `POST /createSignedUrl` -> `POST /core/create-signed-url` +- `POST /invokeLLM` -> `POST /core/invoke-llm` +3. Auth model: +- `GET /health` is public in dev +- all other routes require `Authorization: Bearer ` +4. Standard error envelope: +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "optional-request-id" +} +``` +5. Response header: +- `X-Request-Id` + +## 3) Core routes + +## 3.1 Upload file +1. Method and route: `POST /core/upload-file` +2. Request format: `multipart/form-data` +3. Fields: +- `file` (required) +- `visibility` (`public` or `private`, optional) +- `category` (optional) +4. Accepted types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +5. Max size: `10 MB` (default) +6. Behavior: real upload to Cloud Storage. +7. Success `200`: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//...", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//...", + "requestId": "uuid" +} +``` +8. Errors: +- `UNAUTHENTICATED` +- `INVALID_FILE_TYPE` +- `FILE_TOO_LARGE` + +## 3.2 Create signed URL +1. Method and route: `POST /core/create-signed-url` +2. Request: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` +3. Security checks: +- bucket must be allowed +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds <= 900` +4. Success `200`: +```json +{ + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" +} +``` +5. Errors: +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.3 Invoke model +1. Method and route: `POST /core/invoke-llm` +2. Request: +```json +{ + "prompt": "...", + "responseJsonSchema": {}, + "fileUrls": [] +} +``` +3. Behavior: +- real Vertex AI call +- model default: `gemini-2.0-flash-001` +- timeout default: `20 seconds` +4. Rate limit: +- `20 requests/minute` per user (default) +- when exceeded: `429 RATE_LIMITED` and `Retry-After` header +5. Success `200`: +```json +{ + "result": {}, + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `MODEL_TIMEOUT` +- `MODEL_FAILED` +- `RATE_LIMITED` + +## 3.4 Create verification job +1. Method and route: `POST /core/verifications` +2. Auth: required +3. Request: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "worker_123", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": {} +} +``` +4. Behavior: +- validates `fileUri` ownership +- requires file existence when `UPLOAD_MOCK=false` and `VERIFICATION_REQUIRE_FILE_EXISTS=true` +- enqueues async verification +5. Success `202`: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.5 Get verification status +1. Method and route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Success `200`: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "requestId": "uuid" +} +``` +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.6 Review verification +1. Method and route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Request: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +4. Success `200`: status becomes `APPROVED` or `REJECTED`. +5. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.7 Retry verification +1. Method and route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Success `202`: status resets to `PENDING`. +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.8 Health +1. Method and route: `GET /health` +2. Success `200`: +```json +{ + "ok": true, + "service": "krow-core-api", + "version": "dev", + "requestId": "uuid" +} +``` + +## 4) Locked defaults +1. Validation library: `zod`. +2. Validation schema location: `backend/core-api/src/contracts/`. +3. Buckets: +- `krow-workforce-dev-public` +- `krow-workforce-dev-private` +4. Model provider: Vertex AI Gemini. +5. Max signed URL expiry: `900` seconds. +6. LLM timeout: `20000` ms. +7. LLM rate limit: `20` requests/minute/user. +8. Verification access mode default: `authenticated`. +9. Verification file existence check default: enabled (`VERIFICATION_REQUIRE_FILE_EXISTS=true`). +10. Verification attire provider default in dev: `vertex` with model `gemini-2.0-flash-lite-001`. +11. Verification government/certification providers: external adapters via configured provider URL/token. diff --git a/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md b/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md new file mode 100644 index 00000000..31be4add --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md @@ -0,0 +1,269 @@ +# M4 Backend Foundation Implementation Plan (Dev First) + +Date: 2026-02-24 +Owner: Wilfred (Technical Lead) +Primary environment: `krow-workforce-dev` + +## 1) Objective +Build a secure, modular, and scalable backend foundation in `dev` without breaking the current frontend while we migrate high-risk writes from direct Data Connect mutations to backend command endpoints. + +## 2) First-principles architecture rules +1. Client apps are untrusted for business-critical writes. +2. Backend is the enforcement layer for validation, permissions, and write orchestration. +3. Multi-entity writes must be atomic, idempotent, and observable. +4. Configuration and deployment must be reproducible by automation. +5. Migration must be backward-compatible until each frontend flow is cut over. + +## 3) Pre-coding gates (must be true before implementation starts) + +## Gate A: Security boundary +1. Frontend sends Firebase token only. No database credentials in client code. +2. Every new backend endpoint validates Firebase token. +3. Data Connect write access strategy is defined: +- keep simple reads available to client +- route high-risk writes through backend command endpoints +4. Upload and signed URL paths are server-controlled. + +## Gate B: Contract standards +1. Standard error envelope is frozen: +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "optional-request-id" +} +``` +2. Request validation layer is chosen and centralized. +3. Route naming strategy is frozen: +- canonical routes under `/core` and `/commands` +- compatibility aliases preserved during migration (`/uploadFile`, `/createSignedUrl`, `/invokeLLM`) +4. Validation standard is locked: +- library: `zod` +- schema location: `backend//src/contracts/` with `core/` and `commands/` subfolders + +## Gate C: Atomicity and reliability +1. Command endpoints support idempotency keys for retry-safe writes. +2. Multi-step write flows are wrapped in single backend transaction boundaries. +3. Domain conflict codes are defined for expected business failures. +4. Idempotency storage is locked: +- store in Cloud SQL table +- key scope: `userId + route + idempotencyKey` +- retain records for 24 hours +- repeated key returns original response + +## Gate D: Automation and operability +1. Makefile is source of truth for backend setup and deploy in dev. +2. Core deploy and smoke test commands exist before feature migration. +3. Logging format and request tracing fields are standardized. + +## 4) Security baseline for foundation phase + +## 4.1 Authentication and authorization +1. Foundation phase is authentication-first. +2. Role-based access control is intentionally deferred. +3. All handlers include a policy hook for future role checks (`can(action, resource, actor)`). + +## 4.2 Data access control model +1. Client retains Data Connect reads required for existing screens. +2. High-risk writes move behind `/commands/*` endpoints. +3. Backend mediates write interactions with Data Connect and Cloud SQL. + +## 4.3 File and URL security +1. Validate file type and size server-side. +2. Separate public and private storage behavior. +3. Signed URL creation checks ownership/prefix scope and expiry limits. +4. Bucket policy split is locked: +- `krow-workforce-dev-public` +- `krow-workforce-dev-private` +- private bucket access only through signed URL + +## 4.4 Model invocation safety +1. Enforce schema-constrained output. +2. Apply per-user rate limits and request timeout. +3. Log model failures with safe redaction (no sensitive prompt leakage in logs). +4. Model provider and timeout defaults are locked: +- provider: Vertex AI Gemini +- max route timeout: 20 seconds +- timeout error code: `MODEL_TIMEOUT` + +## 4.5 Secrets and credentials +1. Runtime secrets come from Secret Manager only. +2. Service accounts use least-privilege roles. +3. No secrets committed in repository files. + +## 5) Modularity baseline + +## 5.1 Backend module boundaries +1. `core` module: upload, signed URL, model invocation, health. +2. `commands` module: business writes and state transitions. +3. `policy` module: validation and future role checks. +4. `data` module: Data Connect adapters and transaction wrappers. +5. `infra` module: logging, tracing, auth middleware, error mapping. + +## 5.2 Contract separation +1. Keep API request/response schemas in one location. +2. Keep domain errors in one registry file. +3. Keep route declarations thin; business logic in services. + +## 5.3 Cloud runtime roles +1. Cloud Run is the primary command and core API execution layer. +2. Cloud Functions v2 is worker-only in this phase: +- upload-related async handlers +- notification jobs +- model-related async helpers when needed + +## 6) Automation baseline + +## 6.1 Makefile requirements +Add `makefiles/backend.mk` and wire it into root `Makefile` with at least: +1. `make backend-enable-apis` +2. `make backend-bootstrap-dev` +3. `make backend-deploy-core` +4. `make backend-deploy-commands` +5. `make backend-deploy-workers` +6. `make backend-smoke-core` +7. `make backend-smoke-commands` +8. `make backend-logs-core` + +## 6.2 CI requirements +1. Backend lint +2. Backend tests +3. Build/package +4. Smoke test against deployed dev route(s) +5. Block merge on failed checks + +## 6.3 Session hygiene +1. Update `TASKS.md` and `CHANGELOG.md` each working session. +2. If a new service/API is added, Makefile target must be added in same change. + +## 7) Migration safety contract (no frontend breakage) +1. Backend routes ship first. +2. Frontend migration is per-feature wave, not big bang. +3. Keep compatibility aliases until clients migrate. +4. Keep existing Data Connect reads during foundation. +5. For each migrated write flow: +- before/after behavior checklist +- rollback path +- smoke verification + +## 8) Scope for foundation build +1. Backend runtime/deploy foundation in dev. +2. Core endpoints: +- `POST /core/upload-file` +- `POST /core/create-signed-url` +- `POST /core/invoke-llm` +- `GET /healthz` +3. Compatibility aliases: +- `POST /uploadFile` +- `POST /createSignedUrl` +- `POST /invokeLLM` +4. Command layer scaffold for first migration routes. +5. Initial migration of highest-risk write paths. + +## 9) Implementation phases + +## Phase 0: Baseline and contracts +Deliverables: +1. Freeze endpoint naming and compatibility aliases. +2. Freeze error envelope and error code registry. +3. Freeze auth middleware interface and policy hook interface. +4. Publish route inventory from web/mobile direct writes. + +Exit criteria: +1. No unresolved contract ambiguity. +2. Team agrees on auth-first now and role-map-later approach. + +## Phase 1: Backend infra and automation +Deliverables: +1. `makefiles/backend.mk` with bootstrap, deploy, smoke, logs targets. +2. Environment templates for backend runtime config. +3. Secret Manager and service account setup automation. + +Exit criteria: +1. A fresh machine can deploy core backend to dev via Make commands. + +## Phase 2: Core endpoint implementation +Deliverables: +1. `/core/upload-file` +2. `/core/create-signed-url` +3. `/core/invoke-llm` +4. `/healthz` +5. Compatibility aliases (`/uploadFile`, `/createSignedUrl`, `/invokeLLM`) + +Exit criteria: +1. API harness passes for core routes. +2. Error, logging, and auth standards are enforced. + +## Phase 3: Command layer scaffold +Deliverables: +1. `/commands/orders/create` +2. `/commands/orders/{orderId}/cancel` +3. `/commands/orders/{orderId}/update` +4. `/commands/shifts/{shiftId}/change-status` +5. `/commands/shifts/{shiftId}/assign-staff` +6. `/commands/shifts/{shiftId}/accept` + +Exit criteria: +1. High-risk writes have backend command alternatives ready. + +## Phase 4: Wave 1 frontend migration +Deliverables: +1. Replace direct writes in selected web/mobile flows. +2. Keep reads stable. +3. Verify no regressions in non-migrated screens. + +Exit criteria: +1. Migrated flows run through backend commands only. +2. Rollback instructions validated. + +## Phase 5: Hardening and handoff +Deliverables: +1. Runbook for deploy, rollback, and smoke. +2. Backend CI pipeline active. +3. Wave 2 and wave 3 migration task list defined. + +Exit criteria: +1. Foundation is reusable for staging/prod with environment changes only. + +## 10) Wave 1 migration inventory (real call sites) + +Web: +1. `apps/web/src/features/operations/tasks/TaskBoard.tsx:100` +2. `apps/web/src/features/operations/orders/OrderDetail.tsx:145` +3. `apps/web/src/features/operations/orders/EditOrder.tsx:84` +4. `apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx:31` +5. `apps/web/src/features/operations/orders/components/AssignStaffModal.tsx:60` +6. `apps/web/src/features/workforce/documents/DocumentVault.tsx:99` + +Mobile: +1. `apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart:232` +2. `apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart:1195` +3. `apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart:68` +4. `apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart:446` +5. `apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart:257` +6. `apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart:51` + +## 11) Definition of done for foundation +1. Core endpoints deployed in dev and validated. +2. Command scaffolding in place for wave 1 writes. +3. Auth-first protection active on all new routes. +4. Idempotency + transaction model defined for command writes. +5. Makefile and CI automation cover bootstrap/deploy/smoke paths. +6. Frontend remains stable during migration. +7. Role-map integration points are documented for next phase. + +## 12) Locked defaults (approved) +1. Idempotency key storage strategy: +- Cloud SQL table, 24-hour retention, keyed by `userId + route + idempotencyKey`. +2. Validation library and schema location: +- `zod` in `backend//src/contracts/` (`core/`, `commands/`). +3. Storage bucket naming and split: +- `krow-workforce-dev-public` and `krow-workforce-dev-private`. +4. Model provider and timeout: +- Vertex AI Gemini, 20-second max timeout. +5. Target response-time objectives (p95): +- `/healthz` under 200ms +- `/core/create-signed-url` under 500ms +- `/commands/*` under 1500ms +- `/core/invoke-llm` under 15000ms diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md new file mode 100644 index 00000000..64f8a5c2 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -0,0 +1,245 @@ +# M4 Core API Frontend Guide (Dev) + +Status: Active +Last updated: 2026-02-24 +Audience: Web and mobile frontend developers + +## 1) Base URLs (dev) +1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` + +## 2) Auth requirements +1. Send Firebase ID token on protected routes: +```http +Authorization: Bearer +``` +2. Health route is public: +- `GET /health` +3. All other routes require Firebase token. + +## 3) Standard error envelope +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +## 4) Core API endpoints + +## 4.1 Upload file +1. Route: `POST /core/upload-file` +2. Alias: `POST /uploadFile` +3. Content type: `multipart/form-data` +4. Form fields: +- `file` (required) +- `visibility` (optional: `public` or `private`, default `private`) +- `category` (optional) +5. Accepted file types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +6. Max upload size: `10 MB` (default) +7. Current behavior: real upload to Cloud Storage (not mock) +8. Success `200` example: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//173...", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//173..._file.pdf", + "requestId": "uuid" +} +``` + +## 4.2 Create signed URL +1. Route: `POST /core/create-signed-url` +2. Alias: `POST /createSignedUrl` +3. Request body: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` +4. Security checks: +- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`) +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds` must be `<= 900` +5. Success `200` example: +```json +{ + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" +} +``` +6. Typical errors: +- `400 VALIDATION_ERROR` (bad payload or expiry too high) +- `403 FORBIDDEN` (path not owned by caller) +- `404 NOT_FOUND` (object does not exist) + +## 4.3 Invoke model +1. Route: `POST /core/invoke-llm` +2. Alias: `POST /invokeLLM` +3. Request body: +```json +{ + "prompt": "Return JSON with keys summary and risk.", + "responseJsonSchema": { + "type": "object", + "properties": { + "summary": { "type": "string" }, + "risk": { "type": "string" } + }, + "required": ["summary", "risk"] + }, + "fileUrls": [] +} +``` +4. Current behavior: real Vertex model call (not mock) +- model: `gemini-2.0-flash-001` +- timeout: `20 seconds` +5. Rate limit: +- per-user `20 requests/minute` (default) +- on limit: `429 RATE_LIMITED` +- includes `Retry-After` header +6. Success `200` example: +```json +{ + "result": { "summary": "text", "risk": "Low" }, + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" +} +``` + +## 4.4 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue an async verification job for an uploaded file. +4. Request body: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": { + "dressCode": "black shoes" + } +} +``` +5. Success `202` example: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Current machine processing behavior in dev: +- `attire`: live vision check using Vertex Gemini Flash Lite model. +- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). +- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). + +## 4.5 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling status from frontend. +4. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "review": null, + "requestId": "uuid" +} +``` + +## 4.6 Review verification +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Purpose: final human decision for the verification. +4. Request body: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "" + }, + "requestId": "uuid" +} +``` + +## 4.7 Retry verification +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: requeue verification to run again. +4. Success `202` example: status resets to `PENDING`. + +## 5) Frontend fetch examples (web) + +## 5.1 Signed URL request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileUri: 'gs://krow-workforce-dev-private/uploads//file.pdf', + expiresInSeconds: 300, + }), +}); +const data = await res.json(); +``` + +## 5.2 Model request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'Return JSON with status.', + responseJsonSchema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + }), +}); +const data = await res.json(); +``` + +## 6) Notes for frontend team +1. Use canonical `/core/*` routes for new work. +2. Aliases exist only for migration compatibility. +3. `requestId` in responses should be logged client-side for debugging. +4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. +5. Verification routes are now available in dev under `/core/verifications*`. +6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). +7. Full verification design and policy details: + `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. diff --git a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md new file mode 100644 index 00000000..8c53fb69 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md @@ -0,0 +1,166 @@ +# M4 Core Data Actors and Example Scenarios + +Status: Working draft +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Core data actors +1. `Tenant`: staffing company boundary and data isolation root. +2. `User`: human identity that signs in. +3. `TenantMembership`: user role/context inside one tenant. +4. `Business`: client account served by the tenant. +5. `BusinessMembership`: maps users to a business with role/status (`owner`, `manager`, `approver`, `viewer`). +6. `Vendor`: supplier account that can fulfill staffing demand. +7. `VendorMembership`: maps users to a vendor with role/status (`owner`, `manager`, `scheduler`, `viewer`). +8. `Workforce/Staff`: worker profile used for assignment and attendance. +9. `StakeholderType`: typed category (`buyer`, `operator`, `vendor_partner`, `workforce`, `partner`, `procurement_partner`). +10. `StakeholderProfile`: typed actor record inside a tenant. +11. `StakeholderLink`: relationship between stakeholder profiles. + +## 1.1 Current schema coverage (today) +Current Data Connect handles this only partially: +1. `Business.userId` supports one primary business user. +2. `Vendor.userId` supports one primary vendor user. +3. `TeamMember` can represent multiple users by team as a workaround. + +This is why we need first-class membership tables: +1. `business_memberships` +2. `vendor_memberships` + +Without those, client/vendor user partitioning is indirect and harder to enforce safely at scale. + +## 2) Minimal actor map + +```mermaid +flowchart LR + T["Tenant"] --> TM["TenantMembership (global tenant access)"] + U["User"] --> TM + T --> B["Business"] + T --> V["Vendor"] + U --> BM["BusinessMembership"] + BM --> B + U --> VM["VendorMembership"] + VM --> V + U --> S["Workforce/Staff"] + T --> SP["StakeholderProfile"] + ST["StakeholderType"] --> SP + SP --> SL["StakeholderLink"] + B --> O["Orders/Shifts"] + V --> O + S --> O +``` + +## 3) Scenario A: Legendary Event Staffing +Context: +1. Tenant is `Legendary Event Staffing and Entertainment`. +2. Business is `Google Mountain View Cafes`. +3. Legendary uses its own workforce, and can still route overflow to approved vendors. + +Actor mapping (text): +1. Tenant: `Legendary Event Staffing and Entertainment` (the company using Krow). +2. User: `Wil` (ops lead), `Maria` (Google client manager), `Omar` (Google procurement approver), `Jose` (vendor scheduler), `Ana` (worker). +3. TenantMembership: +4. `Wil` is `admin` in Legendary tenant. +5. `Maria` is `member` in Legendary tenant. +6. `Omar` is `member` in Legendary tenant. +7. `Jose` is `member` in Legendary tenant. +8. `Ana` is `member` in Legendary tenant. +9. BusinessMembership: +10. `Maria` is `manager` in `Google Mountain View Cafes`. +11. `Omar` is `approver` in `Google Mountain View Cafes`. +12. VendorMembership: +13. `Jose` is `scheduler` in `Legendary Staffing Pool A`. +14. `Wil` is `owner` in `Legendary Staffing Pool A`. +15. Business: `Google Mountain View Cafes` (client account under the tenant). +16. Vendor: `Legendary Staffing Pool A` (or an external approved vendor). +17. Workforce/Staff: `Ana` is a staff profile in workforce, linked to certifications and assignments. +18. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +19. StakeholderProfile: +20. `Google Procurement` = `buyer`. +21. `Legendary Ops` = `operator`. +22. `FoodBuy` = `procurement_partner`. +23. StakeholderLink: +24. `Google Procurement` `contracts_with` `Legendary Ops`. +25. `Legendary Ops` `sources_from` `Legendary Staffing Pool A`. +26. `Google Procurement` `reports_through` `FoodBuy`. + +```mermaid +sequenceDiagram + participant Tenant as "Tenant (Legendary)" + participant BizUser as "Business user (Maria/Omar)" + participant Ops as "Ops user (Wil)" + participant VendorUser as "Vendor user (Jose)" + participant Staff as "Workforce/Staff (Barista Ana)" + participant StakeRel as "StakeholderLink" + + BizUser->>Ops: "Create staffing request" + Ops->>Tenant: "Create order under tenant scope" + Ops->>StakeRel: "Resolve business-to-vendor/workforce relationships" + Ops->>VendorUser: "Dispatch role demand" + VendorUser->>Staff: "Confirm worker assignment" + Staff-->>Ops: "Clock in/out and complete shift" + Ops-->>BizUser: "Invoice/report generated with audit trail" +``` + +## 4) Scenario B: Another tenant (external vendor-heavy) +Context: +1. Tenant is `Peakline Events`. +2. Business is `NVIDIA Campus Dining`. +3. Peakline primarily fulfills demand through external approved vendors. + +Actor mapping (text): +1. Tenant: `Peakline Events` (another staffing company using Krow). +2. User: `Chris` (operations coordinator), `Nina` (client manager), `Sam` (vendor manager), `Leo` (worker). +3. TenantMembership: +4. `Chris` is `admin` in Peakline tenant. +5. `Nina` is `member` in Peakline tenant. +6. `Sam` is `member` in Peakline tenant. +7. `Leo` is `member` in Peakline tenant. +8. BusinessMembership: +9. `Nina` is `manager` in `NVIDIA Campus Dining`. +10. VendorMembership: +11. `Sam` is `manager` in `Metro Staffing LLC`. +12. Business: `NVIDIA Campus Dining` (client account under the tenant). +13. Vendor: `Metro Staffing LLC` (approved external vendor for this tenant). +14. Workforce/Staff: `Leo` is a workforce profile fulfilled through Metro Staffing for assignment and attendance. +15. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +16. StakeholderProfile: +17. `NVIDIA Procurement` = `buyer`. +18. `Peakline Ops` = `operator`. +19. `FoodBuy Regional` = `procurement_partner`. +20. StakeholderLink: +21. `NVIDIA Procurement` `contracts_with` `Peakline Ops`. +22. `Peakline Ops` `sources_from` `Metro Staffing LLC`. +23. `NVIDIA Procurement` `reports_through` `FoodBuy Regional`. + +```mermaid +sequenceDiagram + participant Tenant as "Tenant (Peakline Events)" + participant BizUser as "Business user (Nina)" + participant Ops as "Ops user (Chris)" + participant VendorUser as "Vendor user (Sam)" + participant Staff as "Workforce/Staff (Vendor worker)" + + BizUser->>Ops: "Request recurring event staffing" + Ops->>Tenant: "Create recurring order" + Ops->>VendorUser: "Dispatch required roles" + VendorUser->>Staff: "Provide available workers" + Staff-->>Ops: "Attendance and completion events" + Ops-->>BizUser: "Settlement + performance reports" +``` + +## 5) Why this model scales +1. New stakeholders can be added through `StakeholderType` and `StakeholderProfile` without changing core order/shift tables. +2. Business and vendor user partitioning is explicit through membership tables, not hidden in one owner field. +3. Multi-tenant isolation stays strict because all critical records resolve through `Tenant`. +4. Legendary and non-Legendary operating models both fit the same structure. + +## 6) Industry alignment (primary references) +1. Google Cloud Identity Platform multi-tenancy: +- https://docs.cloud.google.com/identity-platform/docs/multi-tenancy +2. AWS SaaS tenant isolation fundamentals: +- https://docs.aws.amazon.com/whitepapers/latest/saas-architecture-fundamentals/tenant-isolation.html +3. B2B organization-aware login flows (Auth0 Organizations): +- https://auth0.com/docs/manage-users/organizations/login-flows-for-organizations +4. Supplier-side multi-user management patterns (Coupa Supplier Portal): +- https://compass.coupa.com/en-us/products/product-documentation/supplier-resources/for-suppliers/coupa-supplier-portal/set-up-the-csp/users/manage-users diff --git a/docs/MILESTONES/M4/planning/m4-discrepencies.md b/docs/MILESTONES/M4/planning/m4-discrepencies.md deleted file mode 100644 index 86f8f6f6..00000000 --- a/docs/MILESTONES/M4/planning/m4-discrepencies.md +++ /dev/null @@ -1,47 +0,0 @@ -# M4 Planning Phase: Identified Discrepancies and Enhancements - -## Feedback and Discrepancies (Based on M3 Review done by Iliana) - -### Mobile Application (Client & Staff) -- **Flexible Shift Locations:** Feedback from the M3 review indicated a need for the ability to specify different locations for individual positions within an order on the mobile app. Currently, the web application handles this by requiring a separate shift for each location. - -- **Order Visibility Management:** Currently, when an order is created with multiple positions, the "View Order" page displays them as separate orders. This is due to UI (prototype) cannot support multi-position views in the order. We should consider adopting the "legacy" app's approach to group these positions under a single order for better clarity. - -- **Cost Center Clarification:** The purpose and functionality of the "Cost Center" field in the Hub creation process is not clear. - -- **Tax Form Data Completeness:** Feedback noted that while tax forms are visible in the Staff mobile application, they appear to be missing critical information. This not clear. - -### Web Dashboard -- **Role-Based Content Logic:** The current web dashboard prototype contains some logical inconsistencies regarding user roles: - - **Client Dashboard:** Currently includes Staff Availability, Staff Directory, and Staff Onboarding. Since workers (Staff) are managed by Vendors, these pages should be moved to the Vendor dashboard. - - - **Vendor Dashboard:** Currently includes "Teams and Hubs." Since Hubs are client-specific locations where staff clock in/out, these management pages should be moved to the Client dashboard. - - - **Admin Dashboard Filtering:** The Admin dashboard requires improved filtering capabilities. Admins should be able to select specific Clients or Vendors to filter related data, such as viewing only the orders associated with a chosen partner. - -## Proposed Features and Enhancements (Post-M3 Identification) - -- **Feature: Navigation to Hub Details from Coverage Screen (#321)** - - **Description:** Allow users to navigate directly to the Hub Details page by clicking on a hub within the Coverage Screen. - -- **Feature: Dedicated Hub Details Screen with Order History (#320)** - - **Description:** Develop a comprehensive Hub Details view that aggregates all hub-specific data, including historical order records. - - **Benefit:** Centralizes information for better decision-making and easier access to historical data. - -- **Feature: Dedicated Order Details Screen** - - **Description:** Transition from displaying all order information on the primary "View Order" page to a dedicated "Order Details" screen. This screen will support viewing multiple positions within a single order. - - **Benefit:** - - **Improved UX:** Reduces complexity by grouping associated positions together and presenting them in a structured way. - - **Performance:** Optimizes data loading by fetching detailed position information only when requested. - -- **Feature: Optimized Clock-In Page (#350)** - - **Description:** Remove the calendar component from the Clock-In page. Since workers only clock in for current-day assignments, the calendar is unnecessary. - - **Benefit:** Simplifies the interface and reduces user confusion. - -- **Feature: Contextual Shift Actions** - - **Description:** Restrict the Clock-In page to show only active or upcoming shifts (starting within 30 minutes). Shift-specific actions (Clock-In/Clock-Out) should be performed within the specific Shift Details page. - - **Reasoning:** This solves issues where staff cannot clock out of overnight shifts (shifts starting one day and ending the next) due to the current day-based UI. - -- **Feature: Dedicated Emergency Contact Management (#356)** - - **Description:** Replace the inline form in the "View Emergency Contact" page with a dedicated "Create Emergency Contact" screen. - - **Benefit:** Standardizes the data entry process and improves UI organization within the Staff app. diff --git a/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md new file mode 100644 index 00000000..bcd8e7fc --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md @@ -0,0 +1,168 @@ +# M4 Roadmap CSV Schema Reconciliation + +Status: Draft for implementation alignment +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Why this exists +We reviewed the original product-roadmap exports to confirm that the target schema can support all stakeholder lanes as the platform grows. + +This avoids two failure modes: +1. Building command APIs on top of a schema that cannot represent required workflows. +2. Hard-coding today's customer setup in a way that blocks future staffing companies. + +## 2) Inputs reviewed +All 13 roadmap exports from `/Users/wiel/Downloads`: +1. `Krow App – Roadmap - Business App_ Google, Nvidia.csv` +2. `Krow App – Roadmap - Client_ Google, Nvidia.csv` +3. `Krow App – Roadmap - Compass- The Operator.csv` +4. `Krow App – Roadmap - Employee App.csv` +5. `Krow App – Roadmap - Features.csv` +6. `Krow App – Roadmap - FoodBuy- Procurement.csv` +7. `Krow App – Roadmap - KROW Dashboard.csv` +8. `Krow App – Roadmap - Offenses.csv` +9. `Krow App – Roadmap - Partner.csv` +10. `Krow App – Roadmap - Roadmap.csv` +11. `Krow App – Roadmap - Sectors_ BA, Flik ( The executors).csv` +12. `Krow App – Roadmap - The Workforce_ Employees.csv` +13. `Krow App – Roadmap - Vendor_ Legendary (Staffing).csv` + +Parsed signal: +1. 983 non-empty task lines. +2. 1,263 planning rows with task/status/priority/reference signals. + +## 3) What the roadmap is clearly asking for +Cross-file recurring capabilities: +1. Multi-party org model: client, operator, vendor, procurement, workforce, partner, sector execution. +2. Orders and shift operations: recurring events, assignment, coverage, schedule management. +3. Attendance and policy enforcement: clock-in/out, no-show, tardiness, cancellation, offense ladders. +4. Compliance and document verification: certifications, insurance, legal docs, renewals, risk alerts. +5. Finance and settlement: invoice lifecycle, disputes, remittance, payment history, aging, payroll/earnings. +6. Reporting and prediction: dashboards, KPI, forecasting, scenario planning. + +Repeated examples across many sheets: +1. `Vendor Onboarding`, `Service Locations`, `Compliance`, `Certifications`. +2. `All Invoices (Open/Pending/Overdue/Paid...)`, `Payment Summary`, `Remittance Advice Download`. +3. Offense progression rules in `Offenses.csv` and `Employee App.csv` (warning -> suspension -> disable/block). + +## 4) Stakeholder capability matrix (from roadmap exports) + +| Stakeholder lane | Org network | Orders and shifts | Attendance and offense | Compliance docs | Finance | Reporting | +|---|---|---|---|---|---|---| +| Client (Google, Nvidia) | Yes | Yes | Partial (visibility) | Yes | Yes | Yes | +| Vendor (Legendary) | Yes | Yes | Yes | Yes | Yes | Yes | +| Workforce (Employee app) | Limited | Yes | Yes | Yes | Earnings focus | Limited | +| Operator (Compass) | Yes | Yes | KPI visibility | Yes | Yes | Yes | +| Procurement (FoodBuy) | Yes | KPI/SLA focus | KPI/SLA focus | Yes | Yes | Yes | +| KROW Dashboard | Cross-entity | Cross-entity | Cross-entity risk | Cross-entity | Cross-entity | Heavy | +| Partner | Basic | Basic | Minimal | Yes | Basic | Basic | + +Implication: +1. This is a multi-tenant, multi-party workflow platform, not a single-role CRUD app. +2. Schema must separate party identity, party relationships, and role-based permissions from workflow records. + +## 5) Reconciliation against current Data Connect schema + +What already exists and is useful: +1. Core scheduling entities: `orders`, `shifts`, `shift_roles`, `applications`, `assignments`. +2. Workforce entities: `staffs`, `workforce`, `staff_roles`. +3. Financial entities: `invoices`, `recent_payments`, `vendor_rates`. +4. Compliance entities: `documents`, `staff_documents`, `certificates`. + +Current structural gaps for roadmap scale: +1. No tenant boundary key on core tables (`tenant_id` missing). +2. No first-class business user partitioning table (`business_memberships` missing). +3. No first-class vendor user partitioning table (`vendor_memberships` missing). +4. No first-class stakeholder profile/link model for buyer/operator/partner/sector relationships. +5. Attendance history is not first-class (check in/out only inside `applications`). +6. No offense policy, offense event, or enforcement action tables. +7. Finance is coarse (invoice + recent payment), missing line items, payment runs, remittance artifact model. +8. Sensitive bank fields are currently modeled directly in `accounts` (`accountNumber`, `routeNumber`). +9. Many core workflow fields are JSON (`orders.assignedStaff`, `orders.shifts`, `shift.managers`, `assignment.managers`). +10. Money still uses float in critical tables. + +Connector boundary gap: +1. 147 Data Connect mutation operations exist. +2. 36 of those are high-risk core workflow mutations (`order`, `shift`, `shiftRole`, `application`, `assignment`, `invoice`, `vendor`, `business`, `workForce`, `teamMember`, `account`). + +## 6) Target schema additions required before full command rollout + +### 6.1 Tenant and stakeholder graph +1. `tenants` +2. `tenant_memberships` +3. `business_memberships` +4. `vendor_memberships` +5. `stakeholder_types` +6. `stakeholder_profiles` +7. `stakeholder_links` +8. `role_bindings` (scoped to tenant/team/hub/business/vendor/resource) + +### 6.2 Attendance and timesheet reliability +1. `attendance_events` (append-only clock-in/out/NFC/manual-corrected) +2. `attendance_sessions` (derived per shift role assignment) +3. `timesheets` (approval state and pay-eligible snapshot) +4. `timesheet_adjustments` (manual corrections with audit reason) + +### 6.3 Offense and policy enforcement +1. `offense_policies` (per tenant or per business) +2. `offense_rules` (threshold and consequence ladder) +3. `offense_events` (who, what, when, evidence) +4. `enforcement_actions` (warning, suspension, disable, block-from-client) + +### 6.4 Compliance and verification +1. `verification_jobs` +2. `verification_reviews` +3. `verification_events` +4. `compliance_requirements` (per role, tenant, business, or client contract) + +### 6.5 Finance completeness +1. `invoice_line_items` +2. `invoice_status_history` +3. `payment_runs` +4. `payment_allocations` +5. `remittance_documents` +6. `account_token_refs` (tokenized provider refs, no raw account/routing) + +## 7) Minimal target relationship view + +```mermaid +erDiagram + TENANT ||--o{ TENANT_MEMBERSHIP : has + BUSINESS ||--o{ BUSINESS_MEMBERSHIP : has + VENDOR ||--o{ VENDOR_MEMBERSHIP : has + USER ||--o{ BUSINESS_MEMBERSHIP : belongs_to + USER ||--o{ VENDOR_MEMBERSHIP : belongs_to + TENANT ||--o{ STAKEHOLDER_PROFILE : has + STAKEHOLDER_PROFILE ||--o{ STAKEHOLDER_LINK : links_to + + TENANT ||--o{ BUSINESS : owns + TENANT ||--o{ VENDOR : owns + BUSINESS ||--o{ ORDER : requests + VENDOR ||--o{ ORDER : fulfills + ORDER ||--o{ SHIFT : expands_to + SHIFT ||--o{ SHIFT_ROLE : requires + SHIFT_ROLE ||--o{ APPLICATION : receives + APPLICATION ||--o{ ASSIGNMENT : becomes + + ASSIGNMENT ||--o{ ATTENDANCE_EVENT : emits + ASSIGNMENT ||--o{ TIMESHEET : settles + OFFENSE_POLICY ||--o{ OFFENSE_RULE : defines + ASSIGNMENT ||--o{ OFFENSE_EVENT : may_trigger + OFFENSE_EVENT ||--o{ ENFORCEMENT_ACTION : causes + + ORDER ||--o{ INVOICE : bills + INVOICE ||--o{ INVOICE_LINE_ITEM : contains + PAYMENT_RUN ||--o{ PAYMENT_ALLOCATION : allocates + INVOICE ||--o{ PAYMENT_ALLOCATION : receives + PAYMENT_RUN ||--o{ REMITTANCE_DOCUMENT : publishes +``` + +## 8) First-principles rules we should lock now +1. Every command-critical table includes `tenant_id`. +2. High-risk writes go through command APIs only. +3. Money uses exact numeric type (or integer cents), never float. +4. Core workflow state is relational and constrained, not JSON blobs. +5. Every irreversible state change has append-only audit event rows. + +## 9) Decision +This roadmap evidence supports continuing with the target architecture direction (command boundary + multi-tenant schema), but we should add attendance/offense/settlement/stakeholder-graph tables before full command rollout on mission-critical flows. diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md new file mode 100644 index 00000000..2f81014b --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md @@ -0,0 +1,490 @@ +# M4 Target Schema Blueprint (Command-Ready) + +Status: Draft for team alignment +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Goal +Define the target database shape we want **before** command-backend implementation, so critical flows are atomic, secure, and scalable. + +## 1.1 Stakeholder and tenancy model +This product should be designed as a **multi-tenant platform**. + +1. Tenant: +- One staffing company account (example: Legendary Event Staffing and Entertainment). +2. Business: +- A customer/client account owned by a tenant. +3. User: +- A human identity (auth account) that can belong to one or more tenants. +4. Staff: +- A workforce profile linked to a user identity and tenant-scoped operations. + +Practical meaning: +1. The same platform can serve multiple staffing companies safely. +2. Data isolation is by `tenant_id`, not only by business/vendor IDs. +3. Not every record starts as a full active user: +- invite-first or pending onboarding records are valid, +- then bound to `user_id` when activation is completed. +4. Business-side users and vendor-side users are partitioned with dedicated membership tables, not only one `userId` owner field. + +```mermaid +flowchart LR + U["User identity"] --> M["Tenant membership"] + M --> T["Tenant staffing company"] + T --> B["Business client"] + T --> V["Vendor partner"] + B --> O["Orders and shifts"] + V --> O +``` + +## 1.2 Stakeholder wheel mapping (current baseline) +The stakeholder labels from the customer workshop map to schema as follows: + +1. Buyer (Procurements): +- Buyer users inside a business/client account. +- Schema anchor: `users` + `tenant_memberships` + `business_memberships`. +2. Enterprises (Operator): +- Tenant operator/admin users running staffing operations. +- Schema anchor: `tenants`, `tenant_memberships`, `role_bindings`. +3. Sectors (Execution): +- Operational segments or business units executing events. +- Schema anchor: `teams`, `team_hubs`, `team_hud_departments`, `roles`. +4. Approved Vendor: +- Supplier companies approved to fulfill staffing demand. +- Schema anchor: `vendors`, `vendor_memberships`, `workforce`, `vendor_rates`, `vendor_benefit_plans`. +5. Workforce: +- Individual workers/staff and their assignments. +- Schema anchor: `staffs`, `staff_roles`, `applications`, `assignments`, `certificates`, `staff_documents`. +6. Partner: +- External integration or service partner (future). +- Schema anchor: `stakeholder_profiles` extension path + scoped role bindings. + +Rule: +1. Start with baseline stakeholders above. +2. Add new stakeholders via extension tables and role bindings, not by changing core scheduling and finance tables. + +## 1.3 Future stakeholder expansion model +To add stakeholders later without breaking core schema: +1. Add `stakeholder_types` (registry). +2. Add `stakeholder_profiles` (`tenant_id`, `type`, `status`, `metadata`). +3. Add `stakeholder_links` (relationship graph across stakeholders). +4. Bind permissions through `role_bindings` with scope (`tenant`, `team`, `hub`, `business`, or specific resource). + +## 1.4 Roadmap CSV evidence snapshot +Evidence source: +1. `docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md` + +What the exports confirmed: +1. The product is multi-party and multi-tenant by design (client, operator, vendor, workforce, procurement, partner, dashboard). +2. Attendance and offense enforcement are core business workflows, not side features. +3. Finance requires more than invoices (payment runs, remittance, status history, dispute/audit trace). +4. Compliance requires asynchronous verification and requirement templates by tenant/business/role. + +## 2) First-principles rules +1. Every critical write must be server-mediated and transactional. +2. Tenant boundaries must be explicit in data and queries. +3. Money and rates must use exact numeric types, not floating point. +4. Data needed for constraints should be relational, not hidden in JSON blobs. +5. Every high-risk state transition must be auditable and replayable. + +## 3) Current anti-patterns we are removing +1. Direct client mutation of core entities. +2. Broad `USER`-auth CRUD without strict tenant scoping. +3. Financial values as `Float`. +4. Core workflow state embedded in generic `Any/jsonb` fields. +5. Missing uniqueness/index constraints on high-traffic paths. + +## 4) Target modular schema + +## 4.1 Identity and Access +Tables: +1. `users` (source identity, profile, auth linkage) +2. `tenant_memberships` (new; membership + base access per tenant) +3. `business_memberships` (new; user access to business account scope) +4. `vendor_memberships` (new; user access to vendor account scope) +5. `team_members` (membership + scope per team) +6. `roles` (new) +7. `permissions` (new) +8. `role_bindings` (new; who has which role in which scope) + +Rules: +1. Unique tenant membership: `(tenant_id, user_id)`. +2. Unique business membership: `(business_id, user_id)`. +3. Unique vendor membership: `(vendor_id, user_id)`. +4. Unique team membership: `(team_id, user_id)`. +5. Access checks resolve through tenant membership first, then business/vendor/team scope. + +## 4.2 Organization and Tenant +Tables: +1. `tenants` (new canonical boundary: business/vendor ownership root) +2. `businesses` +3. `vendors` +4. `teams` +5. `team_hubs` +6. `hubs` + +Rules: +1. Every command-critical row references `tenant_id`. +2. All list queries must include tenant predicate. +3. Business and vendor routes must enforce membership scope before data access. + +## 4.8 RBAC rollout strategy (deferred enforcement) +RBAC should be introduced in phases and **not enforced everywhere immediately**. + +Phase A: Auth-first (now) +1. Require valid auth token. +2. Resolve tenant context. +3. Allow current work to continue while logging actor + tenant + action. + +Phase B: Shadow RBAC +1. Evaluate permissions (`allow`/`deny`) in backend. +2. Log decisions but do not block most requests yet. +3. Start with warnings and dashboards for denied actions. + +Phase C: Enforced RBAC on command writes +1. Enforce RBAC on `/commands/*` only. +2. Keep low-risk read flows in transition mode. + +Phase D: Enforced RBAC on high-risk reads +1. Enforce tenant and role checks on sensitive read connectors. +2. Remove remaining broad user-level access. + +```mermaid +flowchart LR + A["Auth only"] --> B["Shadow RBAC logging"] + B --> C["Enforce RBAC on command writes"] + C --> D["Enforce RBAC on sensitive reads"] +``` + +## 4.3 Scheduling and Orders +Tables: +1. `orders` +2. `order_schedule_rules` (new; replaces schedule JSON fields) +3. `shifts` +4. `shift_roles` +5. `shift_role_requirements` (optional extension for policy rules) +6. `shift_managers` (new; replaces `managers: [Any!]`) + +Rules: +1. No denormalized `assignedStaff` or `shifts` JSON in `orders`. +2. Time constraints: `start_time < end_time`. +3. Capacity constraints: `assigned <= count`, `filled <= workers_needed`. +4. Canonical status names (single spelling across schema). + +## 4.4 Staffing and Matching +Tables: +1. `staffs` +2. `staff_roles` +3. `workforce` +4. `applications` +5. `assignments` + +Rules: +1. One active workforce relation per `(vendor_id, staff_id)`. +2. One application per `(shift_id, role_id, staff_id)` unless versioned intentionally. +3. Assignment state transitions only through command APIs. + +## 4.5 Compliance and Verification +Tables: +1. `documents` +2. `staff_documents` +3. `certificates` +4. `verification_jobs` +5. `verification_reviews` +6. `verification_events` + +Rules: +1. Verification is asynchronous and append-only for events. +2. Manual review is explicit and tracked. +3. Government ID and certification provider references are persisted. + +## 4.6 Financial and Payout +Tables: +1. `invoices` +2. `invoice_templates` +3. `recent_payments` +4. `accounts` (refactor to tokenized provider references) + +Rules: +1. Replace monetary `Float` with exact numeric (`DECIMAL(12,2)` or integer cents). +2. Do not expose raw account/routing values in query connectors. +3. Add one-primary-account constraint per owner. + +## 4.7 Audit and Reliability +Tables: +1. `domain_events` (new) +2. `idempotency_keys` (already started in command API SQL) +3. `activity_logs` + +Rules: +1. Every command write emits a domain event. +2. Idempotency scope: `(actor_uid, route, idempotency_key)`. + +## 4.9 Attendance, Timesheets, and Offense Governance +Tables: +1. `attendance_events` (append-only: clock-in/out, source, correction metadata) +2. `attendance_sessions` (derived work session per assignment) +3. `timesheets` (approval-ready payroll snapshot) +4. `timesheet_adjustments` (manual edits with reason and actor) +5. `offense_policies` (tenant/business scoped policy set) +6. `offense_rules` (threshold ladder and consequence) +7. `offense_events` (actual violation events) +8. `enforcement_actions` (warning, suspension, disable, block) + +Rules: +1. Attendance corrections are additive events, not destructive overwrites. +2. Offense consequences are computed from policy + history and persisted as explicit actions. +3. Manual overrides require actor, reason, and timestamp in audit trail. + +## 4.10 Stakeholder Network Extensibility +Tables: +1. `stakeholder_types` (buyer, operator, vendor, workforce, partner, future types) +2. `stakeholder_profiles` (tenant-scoped typed profile) +3. `stakeholder_links` (explicit relationship graph between profiles) + +Rules: +1. New stakeholder categories are added by data, not by schema rewrites to core workflow tables. +2. Permission scope resolves through role bindings plus stakeholder links where needed. +3. Scheduling and finance records remain stable while stakeholder topology evolves. + +## 5) Target core model (conceptual) + +```mermaid +erDiagram + TENANT ||--o{ BUSINESS : owns + TENANT ||--o{ VENDOR : owns + TENANT ||--o{ TEAM : owns + TEAM ||--o{ TEAM_MEMBER : has + USER ||--o{ TEAM_MEMBER : belongs_to + USER ||--o{ BUSINESS_MEMBERSHIP : belongs_to + USER ||--o{ VENDOR_MEMBERSHIP : belongs_to + BUSINESS ||--o{ BUSINESS_MEMBERSHIP : has + VENDOR ||--o{ VENDOR_MEMBERSHIP : has + + BUSINESS ||--o{ ORDER : requests + VENDOR ||--o{ ORDER : fulfills + ORDER ||--o{ ORDER_SCHEDULE_RULE : has + ORDER ||--o{ SHIFT : expands_to + SHIFT ||--o{ SHIFT_ROLE : requires + SHIFT ||--o{ SHIFT_MANAGER : has + + USER ||--o{ STAFF : identity + STAFF ||--o{ STAFF_ROLE : skills + VENDOR ||--o{ WORKFORCE : contracts + STAFF ||--o{ WORKFORCE : linked + SHIFT_ROLE ||--o{ APPLICATION : receives + STAFF ||--o{ APPLICATION : applies + SHIFT_ROLE ||--o{ ASSIGNMENT : allocates + WORKFORCE ||--o{ ASSIGNMENT : executes + ASSIGNMENT ||--o{ ATTENDANCE_EVENT : emits + ASSIGNMENT ||--o{ TIMESHEET : settles + OFFENSE_POLICY ||--o{ OFFENSE_RULE : defines + ASSIGNMENT ||--o{ OFFENSE_EVENT : may_trigger + OFFENSE_EVENT ||--o{ ENFORCEMENT_ACTION : causes + + STAFF ||--o{ CERTIFICATE : has + STAFF ||--o{ STAFF_DOCUMENT : uploads + DOCUMENT ||--o{ STAFF_DOCUMENT : references + STAFF ||--o{ VERIFICATION_JOB : subject + VERIFICATION_JOB ||--o{ VERIFICATION_REVIEW : reviewed_by + VERIFICATION_JOB ||--o{ VERIFICATION_EVENT : logs + + ORDER ||--o{ INVOICE : billed_by + INVOICE ||--o{ RECENT_PAYMENT : settles + TENANT ||--o{ ACCOUNT_TOKEN_REF : payout_method + INVOICE ||--o{ INVOICE_LINE_ITEM : details + PAYMENT_RUN ||--o{ PAYMENT_ALLOCATION : allocates + INVOICE ||--o{ PAYMENT_ALLOCATION : receives + PAYMENT_RUN ||--o{ REMITTANCE_DOCUMENT : publishes + + ORDER ||--o{ DOMAIN_EVENT : emits + SHIFT ||--o{ DOMAIN_EVENT : emits + ASSIGNMENT ||--o{ DOMAIN_EVENT : emits + STAKEHOLDER_TYPE ||--o{ STAKEHOLDER_PROFILE : classifies + STAKEHOLDER_PROFILE ||--o{ STAKEHOLDER_LINK : relates +``` + +## 6) Command write boundary on this schema + +```mermaid +flowchart LR + A["Frontend app"] --> B["Command API"] + B --> C["Policy + validation"] + C --> D["Single database transaction"] + D --> E["orders, shifts, shift_roles, applications, assignments"] + D --> F["domain_events + idempotency_keys"] + E --> G["Read models and reports"] +``` + +## 7) Minimum constraints and indexes to add before command build + +## 7.1 Constraints +1. `shift_roles`: check `assigned >= 0 AND assigned <= count`. +2. `shifts`: check `start_time < end_time`. +3. `applications`: unique `(shift_id, role_id, staff_id)`. +4. `workforce`: unique active `(vendor_id, staff_id)`. +5. `team_members`: unique `(team_id, user_id)`. +6. `accounts` (or token ref table): unique primary per owner. +7. `attendance_events`: unique idempotency tuple (for example `(assignment_id, source_event_id)`). +8. `offense_rules`: unique `(policy_id, trigger_type, threshold_count)`. + +## 7.2 Indexes +1. `orders (tenant_id, status, date)`. +2. `shifts (order_id, date, status)`. +3. `shift_roles (shift_id, role_id, start_time)`. +4. `applications (shift_id, role_id, status, created_at)`. +5. `assignments (workforce_id, shift_id, role_id, status)`. +6. `verification_jobs (subject_id, type, status, created_at)`. +7. `invoices (business_id, vendor_id, status, due_date)`. +8. `attendance_events (assignment_id, event_time, event_type)`. +9. `offense_events (staff_id, occurred_at, offense_type, status)`. +10. `invoice_line_items (invoice_id, line_type, created_at)`. + +## 8) Data type normalization +1. Monetary: `Float -> DECIMAL(12,2)` (or integer cents). +2. Generic JSON fields in core scheduling: split into relational tables. +3. Timestamps: store UTC and enforce server-generated creation/update fields. + +## 9) Security boundary in schema/connectors +1. Remove broad list queries for sensitive entities unless tenant-scoped. +2. Strip sensitive fields from connector query payloads (bank/routing). +3. Keep high-risk mutations behind command API; Data Connect remains read-first for client. + +## 10) Migration phases (schema-first) + +```mermaid +flowchart TD + P0["Phase 0: Safety patch + - lock sensitive fields + - enforce tenant-scoped queries + - freeze new direct write connectors"] --> P1["Phase 1: Core constraints + - add unique/check constraints + - add indexes + - normalize money types"] + P1 --> P2["Phase 2: Tenant and RBAC base tables + - add tenants and tenant_memberships + - add roles permissions role_bindings + - run RBAC in shadow mode"] + P2 --> P3["Phase 3: Scheduling normalization + - remove order JSON workflow fields + - add order_schedule_rules and shift_managers + - add attendance and offense base tables"] + P3 --> P4["Phase 4: Command rollout + - command writes on hardened schema + - emit domain events + idempotency + - enforce RBAC for command routes + - add finance settlement tables for payment runs and remittance"] + P4 --> P5["Phase 5: Read migration + cleanup + - migrate frontend reads as needed + - enforce RBAC for sensitive reads + - retire deprecated connectors"] +``` + +## 11) Definition of ready for command backend +1. P0 and P1 complete in `dev`. +2. Tenant scoping verified in connector tests. +3. Sensitive field exposure removed. +4. Core transaction invariants enforced by schema constraints. +5. Command API contracts mapped to new normalized tables. +6. RBAC is in shadow mode with decision logs in place (not hard-blocking yet). +7. Attendance and offense tables are ready for policy-driven command routes. +8. Finance settlement tables (`invoice_line_items`, `payment_runs`, `payment_allocations`) are available. + +## 12) Full current model relationship map (all models) + +```mermaid +flowchart LR + Account["Account"] + ActivityLog["ActivityLog"] + Application["Application"] + Assignment["Assignment"] + AttireOption["AttireOption"] + BenefitsData["BenefitsData"] + Business["Business"] + Category["Category"] + Certificate["Certificate"] + ClientFeedback["ClientFeedback"] + Conversation["Conversation"] + Course["Course"] + CustomRateCard["CustomRateCard"] + Document["Document"] + EmergencyContact["EmergencyContact"] + FaqData["FaqData"] + Hub["Hub"] + Invoice["Invoice"] + InvoiceTemplate["InvoiceTemplate"] + Level["Level"] + MemberTask["MemberTask"] + Message["Message"] + Order["Order"] + RecentPayment["RecentPayment"] + Role["Role"] + RoleCategory["RoleCategory"] + Shift["Shift"] + ShiftRole["ShiftRole"] + Staff["Staff"] + StaffAvailability["StaffAvailability"] + StaffAvailabilityStats["StaffAvailabilityStats"] + StaffCourse["StaffCourse"] + StaffDocument["StaffDocument"] + StaffRole["StaffRole"] + Task["Task"] + TaskComment["TaskComment"] + TaxForm["TaxForm"] + Team["Team"] + TeamHub["TeamHub"] + TeamHudDepartment["TeamHudDepartment"] + TeamMember["TeamMember"] + User["User"] + UserConversation["UserConversation"] + Vendor["Vendor"] + VendorBenefitPlan["VendorBenefitPlan"] + VendorRate["VendorRate"] + Workforce["Workforce"] + + Application --> Shift + Application --> ShiftRole + Application --> Staff + Assignment --> ShiftRole + Assignment --> Workforce + BenefitsData --> Staff + BenefitsData --> VendorBenefitPlan + Certificate --> Staff + ClientFeedback --> Business + ClientFeedback --> Vendor + Course --> Category + Invoice --> Business + Invoice --> Order + Invoice --> Vendor + InvoiceTemplate --> Business + InvoiceTemplate --> Order + InvoiceTemplate --> Vendor + MemberTask --> Task + MemberTask --> TeamMember + Message --> User + Order --> Business + Order --> TeamHub + Order --> Vendor + RecentPayment --> Application + RecentPayment --> Invoice + Shift --> Order + ShiftRole --> Role + ShiftRole --> Shift + StaffAvailability --> Staff + StaffAvailabilityStats --> Staff + StaffDocument --> Document + StaffRole --> Role + StaffRole --> Staff + TaskComment --> TeamMember + TeamHub --> Team + TeamHudDepartment --> TeamHub + TeamMember --> Team + TeamMember --> TeamHub + TeamMember --> User + UserConversation --> Conversation + UserConversation --> User + VendorBenefitPlan --> Vendor + VendorRate --> Vendor + Workforce --> Staff + Workforce --> Vendor +``` diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md new file mode 100644 index 00000000..ebfb3dc8 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md @@ -0,0 +1,264 @@ +# M4 Target Schema Models, Keys, and Relationships + +Status: Draft for architecture workshop +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Purpose +This document is the model-level view for slide creation: +1. Key fields per model (`PK`, `FK`, unique keys). +2. How models relate to each other. +3. A first-principles structure for scale across tenant, business, vendor, and workforce flows. + +## 2) Identity and access models + +### 2.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `users` | `id` | - | `email` (optional unique) | +| `tenants` | `id` | - | `slug` | +| `tenant_memberships` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, user_id)` | +| `business_memberships` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `user_id -> users.id` | `(business_id, user_id)` | +| `vendor_memberships` | `id` | `tenant_id -> tenants.id`, `vendor_id -> vendors.id`, `user_id -> users.id` | `(vendor_id, user_id)` | +| `roles` | `id` | `tenant_id -> tenants.id` | `(tenant_id, name)` | +| `permissions` | `id` | - | `code` | +| `role_bindings` | `id` | `tenant_id -> tenants.id`, `role_id -> roles.id`, `user_id -> users.id` | `(tenant_id, role_id, user_id, scope_type, scope_id)` | + +### 2.2 Diagram + +```mermaid +erDiagram + USERS ||--o{ TENANT_MEMBERSHIPS : has + TENANTS ||--o{ TENANT_MEMBERSHIPS : has + + USERS ||--o{ BUSINESS_MEMBERSHIPS : has + BUSINESSES ||--o{ BUSINESS_MEMBERSHIPS : has + TENANTS ||--o{ BUSINESS_MEMBERSHIPS : scopes + + USERS ||--o{ VENDOR_MEMBERSHIPS : has + VENDORS ||--o{ VENDOR_MEMBERSHIPS : has + TENANTS ||--o{ VENDOR_MEMBERSHIPS : scopes + + TENANTS ||--o{ ROLES : defines + ROLES ||--o{ ROLE_BINDINGS : used_by + USERS ||--o{ ROLE_BINDINGS : receives + TENANTS ||--o{ ROLE_BINDINGS : scopes +``` +``` + +## 3) Organization and stakeholder models + +### 3.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `businesses` | `id` | `tenant_id -> tenants.id` | `(tenant_id, business_name)` | +| `vendors` | `id` | `tenant_id -> tenants.id` | `(tenant_id, company_name)` | +| `teams` | `id` | `tenant_id -> tenants.id` | `(tenant_id, team_name)` | +| `team_hubs` | `id` | `team_id -> teams.id` | `(team_id, hub_name)` | +| `team_hud_departments` | `id` | `team_hub_id -> team_hubs.id` | `(team_hub_id, name)` | +| `stakeholder_types` | `id` | - | `code` | +| `stakeholder_profiles` | `id` | `tenant_id -> tenants.id`, `stakeholder_type_id -> stakeholder_types.id` | `(tenant_id, stakeholder_type_id, name)` | +| `stakeholder_links` | `id` | `tenant_id -> tenants.id`, `from_profile_id -> stakeholder_profiles.id`, `to_profile_id -> stakeholder_profiles.id` | `(tenant_id, from_profile_id, to_profile_id, relation_type)` | + +### 3.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ BUSINESSES : owns + TENANTS ||--o{ VENDORS : owns + TENANTS ||--o{ TEAMS : owns + TEAMS ||--o{ TEAM_HUBS : has + TEAM_HUBS ||--o{ TEAM_HUD_DEPARTMENTS : has + + STAKEHOLDER_TYPES ||--o{ STAKEHOLDER_PROFILES : classifies + TENANTS ||--o{ STAKEHOLDER_PROFILES : owns + STAKEHOLDER_PROFILES ||--o{ STAKEHOLDER_LINKS : from + STAKEHOLDER_PROFILES ||--o{ STAKEHOLDER_LINKS : to + TENANTS ||--o{ STAKEHOLDER_LINKS : scopes +``` +``` + +## 4) Workforce, orders, and assignments + +### 4.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `staffs` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, user_id)` (nullable until activation if needed) | +| `staff_roles` | `id` | `staff_id -> staffs.id`, `role_id -> roles.id` | `(staff_id, role_id)` | +| `workforce` | `id` | `tenant_id -> tenants.id`, `vendor_id -> vendors.id`, `staff_id -> staffs.id` | active `(vendor_id, staff_id)` | +| `orders` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `vendor_id -> vendors.id` | `(tenant_id, external_ref)` optional | +| `order_schedule_rules` | `id` | `order_id -> orders.id` | `(order_id, rule_type, effective_from)` | +| `shifts` | `id` | `tenant_id -> tenants.id`, `order_id -> orders.id` | `(order_id, start_time, end_time)` | +| `shift_roles` | `id` | `shift_id -> shifts.id`, `role_id -> roles.id` | `(shift_id, role_id)` | +| `shift_managers` | `id` | `shift_id -> shifts.id`, `team_member_id -> team_members.id` | `(shift_id, team_member_id)` | +| `applications` | `id` | `tenant_id -> tenants.id`, `shift_id -> shifts.id`, `role_id -> roles.id`, `staff_id -> staffs.id` | `(shift_id, role_id, staff_id)` | +| `assignments` | `id` | `tenant_id -> tenants.id`, `shift_role_id -> shift_roles.id`, `workforce_id -> workforce.id` | `(shift_role_id, workforce_id)` active | + +### 4.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ STAFFS : owns + USERS ||--o| STAFFS : identity + STAFFS ||--o{ STAFF_ROLES : has + ROLES ||--o{ STAFF_ROLES : classifies + + TENANTS ||--o{ ORDERS : scopes + BUSINESSES ||--o{ ORDERS : requests + VENDORS ||--o{ ORDERS : fulfills + ORDERS ||--o{ ORDER_SCHEDULE_RULES : configures + ORDERS ||--o{ SHIFTS : expands_to + SHIFTS ||--o{ SHIFT_ROLES : requires + ROLES ||--o{ SHIFT_ROLES : typed_as + SHIFTS ||--o{ SHIFT_MANAGERS : has + + VENDORS ||--o{ WORKFORCE : contracts + STAFFS ||--o{ WORKFORCE : linked + + SHIFT_ROLES ||--o{ APPLICATIONS : receives + STAFFS ||--o{ APPLICATIONS : applies + SHIFT_ROLES ||--o{ ASSIGNMENTS : allocates + WORKFORCE ||--o{ ASSIGNMENTS : executes +``` +``` + +## 5) Attendance and offense governance + +### 5.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `attendance_events` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | `(assignment_id, source_event_id)` | +| `attendance_sessions` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | one open session per assignment | +| `timesheets` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id`, `staff_id -> staffs.id` | `(assignment_id)` | +| `timesheet_adjustments` | `id` | `timesheet_id -> timesheets.id`, `actor_user_id -> users.id` | - | +| `offense_policies` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id` nullable | `(tenant_id, name, business_id)` | +| `offense_rules` | `id` | `policy_id -> offense_policies.id` | `(policy_id, trigger_type, threshold_count)` | +| `offense_events` | `id` | `tenant_id -> tenants.id`, `staff_id -> staffs.id`, `assignment_id -> assignments.id` nullable, `rule_id -> offense_rules.id` nullable | `(staff_id, occurred_at, offense_type)` | +| `enforcement_actions` | `id` | `offense_event_id -> offense_events.id`, `actor_user_id -> users.id` | - | + +### 5.2 Diagram + +```mermaid +erDiagram + ASSIGNMENTS ||--o{ ATTENDANCE_EVENTS : emits + ASSIGNMENTS ||--o{ ATTENDANCE_SESSIONS : opens + ASSIGNMENTS ||--o{ TIMESHEETS : settles + TIMESHEETS ||--o{ TIMESHEET_ADJUSTMENTS : adjusts + USERS ||--o{ TIMESHEET_ADJUSTMENTS : made_by + + TENANTS ||--o{ OFFENSE_POLICIES : defines + BUSINESSES ||--o{ OFFENSE_POLICIES : overrides + OFFENSE_POLICIES ||--o{ OFFENSE_RULES : contains + STAFFS ||--o{ OFFENSE_EVENTS : incurs + OFFENSE_RULES ||--o{ OFFENSE_EVENTS : triggered_by + OFFENSE_EVENTS ||--o{ ENFORCEMENT_ACTIONS : causes + USERS ||--o{ ENFORCEMENT_ACTIONS : applied_by +``` +``` + +## 6) Compliance and verification + +### 6.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `documents` | `id` | `tenant_id -> tenants.id` | `(tenant_id, document_type, name)` | +| `staff_documents` | `id` | `staff_id -> staffs.id`, `document_id -> documents.id` | `(staff_id, document_id)` | +| `certificates` | `id` | `staff_id -> staffs.id` | `(staff_id, certificate_number)` optional | +| `compliance_requirements` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id` nullable, `role_id -> roles.id` nullable | `(tenant_id, requirement_type, business_id, role_id)` | +| `verification_jobs` | `id` | `tenant_id -> tenants.id`, `staff_id -> staffs.id`, `document_id -> documents.id` nullable | `(tenant_id, idempotency_key)` | +| `verification_reviews` | `id` | `verification_job_id -> verification_jobs.id`, `reviewer_user_id -> users.id` | - | +| `verification_events` | `id` | `verification_job_id -> verification_jobs.id` | - | + +### 6.2 Diagram + +```mermaid +erDiagram + STAFFS ||--o{ STAFF_DOCUMENTS : uploads + DOCUMENTS ||--o{ STAFF_DOCUMENTS : references + STAFFS ||--o{ CERTIFICATES : has + + TENANTS ||--o{ COMPLIANCE_REQUIREMENTS : defines + BUSINESSES ||--o{ COMPLIANCE_REQUIREMENTS : overrides + ROLES ||--o{ COMPLIANCE_REQUIREMENTS : role_scope + + STAFFS ||--o{ VERIFICATION_JOBS : subject + VERIFICATION_JOBS ||--o{ VERIFICATION_REVIEWS : reviewed + VERIFICATION_JOBS ||--o{ VERIFICATION_EVENTS : logs + USERS ||--o{ VERIFICATION_REVIEWS : reviewer +``` +``` + +## 7) Finance and settlement + +### 7.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `invoices` | `id` | `tenant_id -> tenants.id`, `order_id -> orders.id`, `business_id -> businesses.id`, `vendor_id -> vendors.id` | `(tenant_id, invoice_number)` | +| `invoice_line_items` | `id` | `invoice_id -> invoices.id`, `assignment_id -> assignments.id` nullable | `(invoice_id, line_number)` | +| `invoice_status_history` | `id` | `invoice_id -> invoices.id`, `actor_user_id -> users.id` | - | +| `payment_runs` | `id` | `tenant_id -> tenants.id` | `(tenant_id, run_reference)` | +| `payment_allocations` | `id` | `payment_run_id -> payment_runs.id`, `invoice_id -> invoices.id` | `(payment_run_id, invoice_id)` | +| `remittance_documents` | `id` | `payment_run_id -> payment_runs.id` | `(payment_run_id, document_url)` | +| `account_token_refs` | `id` | `tenant_id -> tenants.id`, `owner_business_id -> businesses.id` nullable, `owner_vendor_id -> vendors.id` nullable | one primary per owner | + +### 7.2 Diagram + +```mermaid +erDiagram + ORDERS ||--o{ INVOICES : billed + BUSINESSES ||--o{ INVOICES : billed_to + VENDORS ||--o{ INVOICES : billed_from + INVOICES ||--o{ INVOICE_LINE_ITEMS : contains + INVOICES ||--o{ INVOICE_STATUS_HISTORY : transitions + USERS ||--o{ INVOICE_STATUS_HISTORY : changed_by + + PAYMENT_RUNS ||--o{ PAYMENT_ALLOCATIONS : allocates + INVOICES ||--o{ PAYMENT_ALLOCATIONS : receives + PAYMENT_RUNS ||--o{ REMITTANCE_DOCUMENTS : publishes + + TENANTS ||--o{ ACCOUNT_TOKEN_REFS : scopes + BUSINESSES ||--o{ ACCOUNT_TOKEN_REFS : business_owner + VENDORS ||--o{ ACCOUNT_TOKEN_REFS : vendor_owner +``` +``` + +## 8) Audit and reliability + +### 8.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `domain_events` | `id` | `tenant_id -> tenants.id`, `actor_user_id -> users.id` | `(tenant_id, aggregate_type, aggregate_id, sequence)` | +| `idempotency_keys` | `id` | `tenant_id -> tenants.id`, `actor_user_id -> users.id` | `(tenant_id, actor_user_id, route, key)` | +| `activity_logs` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, created_at, id)` | + +### 8.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ DOMAIN_EVENTS : scopes + USERS ||--o{ DOMAIN_EVENTS : actor + TENANTS ||--o{ IDEMPOTENCY_KEYS : scopes + USERS ||--o{ IDEMPOTENCY_KEYS : actor + TENANTS ||--o{ ACTIVITY_LOGS : scopes + USERS ||--o{ ACTIVITY_LOGS : actor +``` +``` + +## 9) Practical note for current system +Current schema already has: +1. `businesses.userId` (single business owner user). +2. `vendors.userId` (single vendor owner user). +3. `team_members` (multi-user workaround). + +Target schema improves this by adding explicit: +1. `business_memberships` +2. `vendor_memberships` + +This is the key upgrade for clean client/vendor user partitioning. diff --git a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md new file mode 100644 index 00000000..31ea59b2 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md @@ -0,0 +1,233 @@ +# M4 Verification Architecture Contract (Attire, Government ID, Certification) + +Status: Partially implemented in dev (core endpoints + async in-memory processor) +Date: 2026-02-24 +Owner: Technical Lead + +## Implementation status today (dev) +1. Implemented routes: +- `POST /core/verifications` +- `GET /core/verifications/{verificationId}` +- `POST /core/verifications/{verificationId}/review` +- `POST /core/verifications/{verificationId}/retry` +2. Current processor is in-memory and non-persistent (for fast frontend integration in dev). +3. Next hardening step is persistent job storage and worker execution before staging. +4. Attire uses a live Vertex vision model path with `gemini-2.0-flash-lite-001` by default. +5. Government ID and certification use third-party adapter contracts (provider URL/token envs) and fall back to `NEEDS_REVIEW` when providers are not configured. + +## 1) Goal +Define a single backend verification pipeline for: +1. `attire` +2. `government_id` +3. `certification` + +This contract gives the team exact endpoint behavior, state flow, and ownership before coding. + +## 2) Principles +1. Upload is evidence intake, not final verification. +2. Verification runs asynchronously in backend workers. +3. Model output is a signal, not legal truth. +4. High-risk identity decisions require stronger validation and human audit trail. +5. Every decision is traceable (`who`, `what`, `when`, `why`). + +## 3) Verification types and policy + +## 3.1 Attire +1. Primary check: vision model + rule checks. +2. Typical output: `AUTO_PASS`, `AUTO_FAIL`, or `NEEDS_REVIEW`. +3. Manual override is always allowed. + +## 3.2 Government ID +1. Required path for mission-critical use: third-party identity verification provider. +2. Model/OCR can pre-parse fields but does not replace identity verification. +3. Final status should require either provider success or manual approval by authorized reviewer. + +## 3.3 Certification +1. Preferred path: verify against issuer API/registry when available. +2. If no issuer API: OCR extraction + manual review. +3. Keep evidence of the source used for validation. + +## 4) State model +1. `PENDING` +2. `PROCESSING` +3. `AUTO_PASS` +4. `AUTO_FAIL` +5. `NEEDS_REVIEW` +6. `APPROVED` +7. `REJECTED` +8. `ERROR` + +Rules: +1. `AUTO_*` and `NEEDS_REVIEW` are machine outcomes. +2. `APPROVED` and `REJECTED` are human outcomes. +3. All transitions are append-only in audit events. + +## 5) API contract + +## 5.1 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue verification job for previously uploaded file. +4. Request: +```json +{ + "type": "attire", + "subjectType": "staff", + "subjectId": "staff_123", + "fileUri": "gs://krow-workforce-dev-private/uploads//item.jpg", + "rules": { + "attireType": "shoes", + "expectedColor": "black" + }, + "metadata": { + "shiftId": "shift_123" + } +} +``` +5. Success `202`: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` + +## 5.2 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling from frontend. +4. Success `200`: +```json +{ + "verificationId": "ver_123", + "type": "attire", + "status": "NEEDS_REVIEW", + "confidence": 0.62, + "reasons": ["Color uncertain"], + "extracted": { + "detectedType": "shoe", + "detectedColor": "dark" + }, + "provider": { + "name": "vertex-attire", + "reference": "gemini-2.0-flash-lite-001" + }, + "review": null, + "createdAt": "2026-02-24T15:00:00Z", + "updatedAt": "2026-02-24T15:00:04Z", + "requestId": "uuid" +} +``` + +## 5.3 Review override +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required (reviewer role later; auth-first now + explicit reviewer id logging) +3. Purpose: final human decision and audit reason. +4. Request: +```json +{ + "decision": "APPROVED", + "note": "Document matches required certification", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200`: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "user_456", + "reviewedAt": "2026-02-24T15:02:00Z", + "note": "Document matches required certification", + "reasonCode": "MANUAL_REVIEW" + }, + "requestId": "uuid" +} +``` + +## 5.4 Retry verification job +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: rerun failed or updated checks. +4. Success `202`: status resets to `PENDING`. + +## 6) Worker execution flow +1. API validates payload and ownership of `fileUri`. +2. API writes `verification_jobs` row with `PENDING`. +3. Worker consumes job, marks `PROCESSING`. +4. Worker selects processor by type: +- `attire` -> model + rule scorer +- `government_id` -> provider adapter (+ optional OCR pre-check) +- `certification` -> issuer API adapter or OCR adapter +5. Worker writes machine outcome (`AUTO_PASS`, `AUTO_FAIL`, `NEEDS_REVIEW`, or `ERROR`). +6. Frontend polls status route. +7. Reviewer finalizes with `APPROVED` or `REJECTED` where needed. + +## 7) Data model (minimal) + +## 7.1 Table: `verification_jobs` +1. `id` (pk) +2. `type` (`attire|government_id|certification`) +3. `subject_type`, `subject_id` +4. `owner_uid` +5. `file_uri` +6. `status` +7. `confidence` (nullable) +8. `reasons_json` +9. `extracted_json` +10. `provider_name`, `provider_ref` +11. `created_at`, `updated_at` + +## 7.2 Table: `verification_reviews` +1. `id` (pk) +2. `verification_id` (fk) +3. `decision` (`APPROVED|REJECTED`) +4. `reviewed_by` +5. `note` +6. `reason_code` +7. `reviewed_at` + +## 7.3 Table: `verification_events` +1. `id` (pk) +2. `verification_id` (fk) +3. `from_status`, `to_status` +4. `actor_type` (`system|reviewer`) +5. `actor_id` +6. `details_json` +7. `created_at` + +## 8) Security and compliance notes +1. Restrict verification file paths to owner-owned upload prefixes. +2. Never expose raw private bucket URLs directly. +3. Keep third-party provider secrets in Secret Manager. +4. Log request and decision IDs for every transition. +5. For government ID, keep provider response reference and verification timestamp. + +## 9) Provider configuration (environment variables) +1. Attire model: +- `VERIFICATION_ATTIRE_PROVIDER=vertex` +- `VERIFICATION_ATTIRE_MODEL=gemini-2.0-flash-lite-001` +2. Government ID provider: +- `VERIFICATION_GOV_ID_PROVIDER_URL` +- `VERIFICATION_GOV_ID_PROVIDER_TOKEN` (Secret Manager recommended) +3. Certification provider: +- `VERIFICATION_CERT_PROVIDER_URL` +- `VERIFICATION_CERT_PROVIDER_TOKEN` (Secret Manager recommended) +4. Provider timeout: +- `VERIFICATION_PROVIDER_TIMEOUT_MS` (default `8000`) + +## 10) Frontend integration pattern +1. Upload file via existing `POST /core/upload-file`. +2. Create verification job with returned `fileUri`. +3. Poll `GET /core/verifications/{id}` until terminal state. +4. Show machine status and confidence. +5. For `NEEDS_REVIEW`, show pending-review UI state. + +## 11) Delivery split (recommended) +1. Wave A (fast): attire verification pipeline end-to-end. +2. Wave B: certification verification with issuer adapter + review. +3. Wave C: government ID provider integration + reviewer flow hardening. diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 00000000..fd1f30e1 --- /dev/null +++ b/docs/api-contracts.md @@ -0,0 +1,266 @@ +# KROW Workforce API Contracts + +This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details. + +--- + +## Staff Application + +### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info) +#### Setup / User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, role }` | +| **Notes** | Required after OTP verification to route users. | + +#### Create Default User API +| Field | Description | +|---|---| +| **Endpoint name** | `/createUser` | +| **Purpose** | Inserts a base user record into the system during initial signup. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `role: UserBaseRole` | +| **Outputs** | `id` of newly created User | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. | + +#### Get Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getStaffByUserId` | +| **Purpose** | Finds the specific Staff record associated with the base user ID. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | +| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. | + +#### Update Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaff` | +| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. | +| **Outputs** | `id` | +| **Notes** | Called incrementally during profile setup wizard. | + +### Home Page (worker_home_page.dart) & Benefits Overview +#### Load Today/Tomorrow Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/getApplicationsByStaffId` | +| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | +| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. | + +#### List Recommended Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/listShifts` | +| **Purpose** | Fetches open shifts that are available for the staff to apply to. | +| **Operation** | Query | +| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. | +| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. | + +#### Benefits Summary API +| Field | Description | +|---|---| +| **Endpoint name** | `/listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | +| **Notes** | Calculates `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages (shifts_page.dart) +#### List Available Shifts Filtered +| Field | Description | +|---|---| +| **Endpoint name** | `/filterShifts` | +| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | +| **Operation** | Query | +| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | +| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | +| **Notes** | - | + +#### Get Shift Details +| Field | Description | +|---|---| +| **Endpoint name** | `/getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. | +| **Operation** | Query | +| **Inputs** | `id: UUID!` | +| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | +| **Notes** | - | + +#### Apply To Shift +| Field | Description | +|---|---| +| **Endpoint name** | `/createApplication` | +| **Purpose** | Worker submits an intent to take an open shift. | +| **Operation** | Mutation | +| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` | +| **Outputs** | `Application ID` | +| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. | + +### Availability Page (availability_page.dart) +#### Get Default Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` | +| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | +| **Notes** | - | + +#### Update Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) | +| **Purpose** | Upserts availability preferences. | +| **Operation** | Mutation | +| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | +| **Outputs** | `id` | +| **Notes** | Called individually per day edited. | + +### Payments Page (payments_page.dart) +#### Get Recent Payments +| Field | Description | +|---|---| +| **Endpoint name** | `/listRecentPaymentsByStaffId` | +| **Purpose** | Loads the history of earnings and timesheets completed by the staff. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `Payments { amount, processDate, shiftId, status }` | +| **Notes** | Displays historical metrics under Earnings tab. | + +### Compliance / Profiles (Agreements, W4, I9, Documents) +#### Get Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/getTaxFormsByStaffId` | +| **Purpose** | Check the filing status of I9 and W4 forms. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | +| **Notes** | Required for staff to be eligible for shifts. | + +#### Update Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type. | +| **Operation** | Mutation | +| **Inputs** | `id`, `dataPoints...` | +| **Outputs** | `id` | +| **Notes** | Updates compliance state. | + +--- + +## Client Application + +### Authentication / Intro (Sign In, Get Started) +#### Client User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, userRole }` | +| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. | + +#### Get Business Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getBusinessByUserId` | +| **Purpose** | Maps the authenticated user to their client business context. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Business { id, businessName, email, contactName }` | +| **Notes** | Used to set the working scopes (Business ID) across the entire app. | + +### Hubs Page (client_hubs_page.dart, edit_hub.dart) +#### List Hubs +| Field | Description | +|---|---| +| **Endpoint name** | `/listTeamHubsByBusinessId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` | +| **Notes** | - | + +#### Update / Delete Hub +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` | +| **Purpose** | Edits or archives a Hub location. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) | +| **Outputs** | `id` | +| **Notes** | - | + +### Orders Page (create_order, view_orders) +#### Create Order +| Field | Description | +|---|---| +| **Endpoint name** | `/createOrder` | +| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). | +| **Operation** | Mutation | +| **Inputs** | `businessId`, `eventName`, `orderType`, `status` | +| **Outputs** | `id` (Order ID) | +| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. | + +#### List Orders +| Field | Description | +|---|---| +| **Endpoint name** | `/getOrdersByBusinessId` | +| **Purpose** | Retrieves all ongoing and past staff requests from the client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Orders { id, eventName, shiftCount, status }` | +| **Notes** | - | + +### Billing Pages (billing_page.dart, pending_invoices) +#### List Invoices +| Field | Description | +|---|---| +| **Endpoint name** | `/listInvoicesByBusinessId` | +| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Invoices { id, amountDue, issueDate, status }` | +| **Notes** | Used across all Billing view tabs. | + +#### Mark Invoice +| Field | Description | +|---|---| +| **Endpoint name** | `/updateInvoice` | +| **Purpose** | Marks an invoice as disputed or pays it (changes status). | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `status: InvoiceStatus` | +| **Outputs** | `id` | +| **Notes** | Disputing usually involves setting a memo or flag. | + +### Reports Page (reports_page.dart) +#### Get Coverage Stats +| Field | Description | +|---|---| +| **Endpoint name** | `/getCoverageStatsByBusiness` | +| **Purpose** | Provides data on fulfillments rates vs actual requests. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` | +| **Notes** | Driven mostly by aggregated backend views. | + +--- + +*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.* diff --git a/docs/available_gql.txt b/docs/available_gql.txt new file mode 100644 index 00000000..54380559 Binary files /dev/null and b/docs/available_gql.txt differ diff --git a/docs/research/flutter-testing-tools.md b/docs/research/flutter-testing-tools.md new file mode 100644 index 00000000..86d9b7f8 --- /dev/null +++ b/docs/research/flutter-testing-tools.md @@ -0,0 +1,85 @@ +# 📱 Research: Flutter Integration Testing Evaluation +**Issue:** #533 +**Focus:** Maestro vs. Marionette MCP (LeanCode) +**Status:** ✅ Completed +**Target Apps:** `KROW Client App` & `KROW Staff App` + +--- + +## 1. Executive Summary & Recommendation + +Following a technical spike implementing full authentication flows (Login/Signup) for both KROW platforms, **Maestro is the recommended integration testing framework.** + +While **Marionette MCP** offers an innovative LLM-driven approach for exploratory debugging, it lacks the determinism required for a production-grade CI/CD pipeline. Maestro provides the stability, speed, and native OS interaction necessary to gate our releases effectively. + +### Why Maestro Wins for KROW: +* **Zero-Flake Execution:** Built-in wait logic handles Firebase Auth latency without hard-coded `sleep()` calls. +* **Platform Parity:** Single `.yaml` definitions drive both iOS and Android build variants. +* **Non-Invasive:** Maestro tests the compiled `.apk` or `.app` (Black-box), ensuring we test exactly what the user sees. +* **System Level Access:** Handles native OS permission dialogs (Camera/Location/Notifications) which Marionette cannot "see." + +--- + +## 2. Technical Evaluation Matrix + +| Criteria | Maestro | Marionette MCP | Winner | +| :--- | :--- | :--- | :--- | +| **Test Authoring** | **High Speed:** Declarative YAML; Maestro Studio recorder. | **Variable:** Requires precise Prompt Engineering. | **Maestro** | +| **Execution Latency** | **Low:** Instantaneous interaction (~5s flows). | **High:** LLM API roundtrips (~45s+ flows). | **Maestro** | +| **Environment** | Works on Release/Production builds. | Restricted to Debug/Profile modes. | **Maestro** | +| **CI/CD Readiness** | Native CLI; easy GitHub Actions integration. | High overhead; depends on external AI APIs. | **Maestro** | +| **Context Awareness** | Interacts with Native OS & Bottom Sheets. | Limited to the Flutter Widget Tree. | **Maestro** | + +--- + +## 3. Spike Analysis & Findings + +### Tool A: Maestro (The Standard) +We verified the `login.yaml` and `signup.yaml` flows across both apps. Maestro successfully abstracted the asynchronous nature of our **Data Connect** and **Firebase** backends. + +* **Pros:** * **Semantics Driven:** By targeting `Semantics(identifier: '...')` in our `/design_system/`, tests remain stable even if the UI text changes for localization. + * **Automatic Tolerance:** It detects spinning loaders and waits for destination widgets automatically. +* **Cons:** * Requires strict adherence to adding `Semantics` wrappers on all interactive components. + +### Tool B: Marionette MCP (The Experiment) +We spiked this using the `marionette_flutter` binding and executing via **Cursor/Claude**. + +* **Pros:** * Phenomenal for visual "smoke testing" and live-debugging UI issues via natural language. +* **Cons:** * **Non-Deterministic:** Prone to "hallucinations" during heavy network traffic. + * **Architecture Blocker:** Requires the Dart VM Service to be active, making it impossible to test against hardened production builds. + +--- + +## 4. Implementation & Migration Blueprint + + + +### Phase 1: Semantics Enforcement +We must enforce a linting rule or PR checklist: All interactive widgets in `@krow/design_system` must include a unique `identifier`. + +```dart +// Standardized Implementation +Semantics( + identifier: 'login_submit_button', + child: KrowPrimaryButton( + onPressed: _handleLogin, + label: 'Sign In', + ), +) +``` + +### Phase 2: Repository Structure (Implemented) +Maestro flows are co-located with each app under `auth/`: + +* `apps/mobile/apps/client/maestro/auth/sign_in.yaml` — Client sign-in +* `apps/mobile/apps/client/maestro/auth/sign_up.yaml` — Client sign-up +* `apps/mobile/apps/staff/maestro/auth/sign_in.yaml` — Staff sign-in (phone + OTP) +* `apps/mobile/apps/staff/maestro/auth/sign_up.yaml` — Staff sign-up (phone + OTP) + +Credentials are injected via env variables (never hardcoded). Use `make test-e2e` to run the suite. + +### Phase 3: CI/CD Integration +The Maestro CLI will be added to our **GitHub Actions** workflow to automate quality gates. + +* **Trigger:** Every PR targeting `main` or `develop`. +* **Action:** Generate a build, execute `maestro test`, and block merge on failure. diff --git a/docs/research/maestro-test-run-instructions.md b/docs/research/maestro-test-run-instructions.md new file mode 100644 index 00000000..190b83b0 --- /dev/null +++ b/docs/research/maestro-test-run-instructions.md @@ -0,0 +1,100 @@ +# How to Run Maestro Integration Tests + +Credentials are injected via env variables — **never hardcoded** in YAML. + +## Env variables + +| Flow | Env variables | +|------|---------------| +| **Client sign-in** | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD` | +| **Client sign-up** | `TEST_CLIENT_EMAIL`, `TEST_CLIENT_PASSWORD`, `TEST_CLIENT_COMPANY` | +| **Staff sign-in** | `TEST_STAFF_PHONE`, `TEST_STAFF_OTP` | +| **Staff sign-up** | `TEST_STAFF_SIGNUP_PHONE`, `TEST_STAFF_OTP` | + +**Example values (login):** legendary@krowd.com / Demo2026! (client), 5557654321 / 123456 (staff) + +--- + +## Step-by-step + +### 1. Install Maestro CLI + +**Windows:** Download from [Maestro releases](https://github.com/mobile-dev-inc/maestro/releases), extract, add to PATH. + +**macOS/Linux:** +```bash +curl -Ls "https://get.maestro.mobile.dev" | bash +``` + +### 2. Add Firebase test phone (Staff app) + +Firebase Console → **Authentication** → **Sign-in method** → **Phone** → **Phone numbers for testing**: +- Add **+1 5557654321** with verification code **123456** + +### 3. Build and install apps + +```bash +make mobile-client-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/client/build/app/outputs/flutter-apk/app-debug.apk + +make mobile-staff-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/staff/build/app/outputs/flutter-apk/app-debug.apk +``` + +### 4. Run E2E tests via Makefile + +**Export credentials, then run:** + +```bash +# Client login credentials +export TEST_CLIENT_EMAIL=legendary@krowd.com +export TEST_CLIENT_PASSWORD=Demo2026! +export TEST_CLIENT_COMPANY="Krow Demo" + +# Staff login credentials +export TEST_STAFF_PHONE=5557654321 +export TEST_STAFF_OTP=123456 +export TEST_STAFF_SIGNUP_PHONE=5555550000 # use a new number for signup + +# Run full suite +make test-e2e + +# Or run per app +make test-e2e-client +make test-e2e-staff +``` + +### 5. Run flows directly (without Make) + +```bash +maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml \ + -e TEST_CLIENT_EMAIL=legendary@krowd.com \ + -e TEST_CLIENT_PASSWORD=Demo2026! + +maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml \ + -e TEST_STAFF_PHONE=5557654321 \ + -e TEST_STAFF_OTP=123456 +``` + +--- + +## Folder structure + +``` +apps/mobile/apps/client/maestro/auth/ + sign_in.yaml + sign_up.yaml +apps/mobile/apps/staff/maestro/auth/ + sign_in.yaml + sign_up.yaml +``` + +--- + +## Checklist + +- [ ] Maestro CLI installed +- [ ] Firebase test phone +1 5557654321 / 123456 added +- [ ] Client & Staff apps built and installed +- [ ] Env vars exported +- [ ] `make test-e2e` run from project root diff --git a/makefiles/backend.mk b/makefiles/backend.mk new file mode 100644 index 00000000..5ee113c0 --- /dev/null +++ b/makefiles/backend.mk @@ -0,0 +1,192 @@ +# --- Backend Foundation (Cloud Run + Workers) --- + +BACKEND_REGION ?= us-central1 +BACKEND_ARTIFACT_REPO ?= krow-backend + +BACKEND_CORE_SERVICE_NAME ?= krow-core-api +BACKEND_COMMAND_SERVICE_NAME ?= krow-command-api +BACKEND_RUNTIME_SA_NAME ?= krow-backend-runtime +BACKEND_RUNTIME_SA_EMAIL := $(BACKEND_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com + +BACKEND_CORE_DIR ?= backend/core-api +BACKEND_COMMAND_DIR ?= backend/command-api +BACKEND_WORKERS_DIR ?= backend/cloud-functions + +BACKEND_DEV_PUBLIC_BUCKET ?= krow-workforce-dev-public +BACKEND_DEV_PRIVATE_BUCKET ?= krow-workforce-dev-private +BACKEND_STAGING_PUBLIC_BUCKET ?= krow-workforce-staging-public +BACKEND_STAGING_PRIVATE_BUCKET ?= krow-workforce-staging-private + +ifeq ($(ENV),staging) + BACKEND_PUBLIC_BUCKET := $(BACKEND_STAGING_PUBLIC_BUCKET) + BACKEND_PRIVATE_BUCKET := $(BACKEND_STAGING_PRIVATE_BUCKET) + BACKEND_RUN_AUTH_FLAG := --no-allow-unauthenticated +else + BACKEND_PUBLIC_BUCKET := $(BACKEND_DEV_PUBLIC_BUCKET) + BACKEND_PRIVATE_BUCKET := $(BACKEND_DEV_PRIVATE_BUCKET) + BACKEND_RUN_AUTH_FLAG := --allow-unauthenticated +endif + +BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/core-api:latest +BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest +BACKEND_LOG_LIMIT ?= 100 +BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 +BACKEND_VERIFICATION_ATTIRE_MODEL ?= gemini-2.0-flash-lite-001 +BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS ?= 8000 +BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 +BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 + +.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core + +backend-help: + @echo "--> Backend Foundation Commands" + @echo " make backend-enable-apis [ENV=dev] Enable Cloud Run/Functions/Build/Secret APIs" + @echo " make backend-bootstrap-dev Bootstrap artifact repo, runtime SA, and buckets" + @echo " make backend-migrate-idempotency Create/upgrade idempotency table in Cloud SQL" + @echo " make backend-deploy-core [ENV=dev] Build + deploy core API service" + @echo " make backend-deploy-commands [ENV=dev] Build + deploy command API service" + @echo " make backend-deploy-workers [ENV=dev] Deploy worker scaffold" + @echo " make backend-smoke-core [ENV=dev] Smoke test core /health" + @echo " make backend-smoke-commands [ENV=dev] Smoke test commands /health" + @echo " make backend-logs-core [ENV=dev] Read core service logs" + +backend-enable-apis: + @echo "--> Enabling backend APIs on project [$(GCP_PROJECT_ID)]..." + @for api in \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + cloudfunctions.googleapis.com \ + eventarc.googleapis.com \ + aiplatform.googleapis.com \ + storage.googleapis.com \ + iam.googleapis.com \ + iamcredentials.googleapis.com \ + serviceusage.googleapis.com \ + firebase.googleapis.com; do \ + echo " - $$api"; \ + gcloud services enable $$api --project=$(GCP_PROJECT_ID); \ + done + @echo "✅ Backend APIs enabled." + +backend-bootstrap-dev: backend-enable-apis + @echo "--> Bootstrapping backend foundation for [$(ENV)] on project [$(GCP_PROJECT_ID)]..." + @echo "--> Ensuring Artifact Registry repo [$(BACKEND_ARTIFACT_REPO)] exists..." + @if ! gcloud artifacts repositories describe $(BACKEND_ARTIFACT_REPO) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud artifacts repositories create $(BACKEND_ARTIFACT_REPO) \ + --repository-format=docker \ + --location=$(BACKEND_REGION) \ + --description="KROW backend services" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Artifact Registry repo already exists."; \ + fi + @echo "--> Ensuring runtime service account [$(BACKEND_RUNTIME_SA_NAME)] exists..." + @if ! gcloud iam service-accounts describe $(BACKEND_RUNTIME_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud iam service-accounts create $(BACKEND_RUNTIME_SA_NAME) \ + --display-name="KROW Backend Runtime" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Runtime service account already exists."; \ + fi + @echo "--> Ensuring runtime service account IAM roles..." + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/storage.objectAdmin" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/aiplatform.user" \ + --quiet >/dev/null + @gcloud iam service-accounts add-iam-policy-binding $(BACKEND_RUNTIME_SA_EMAIL) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/iam.serviceAccountTokenCreator" \ + --project=$(GCP_PROJECT_ID) \ + --quiet >/dev/null + @echo "--> Ensuring storage buckets exist..." + @if ! gcloud storage buckets describe gs://$(BACKEND_PUBLIC_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_PUBLIC_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Public bucket already exists: $(BACKEND_PUBLIC_BUCKET)"; \ + fi + @if ! gcloud storage buckets describe gs://$(BACKEND_PRIVATE_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_PRIVATE_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Private bucket already exists: $(BACKEND_PRIVATE_BUCKET)"; \ + fi + @echo "✅ Backend foundation bootstrap complete for [$(ENV)]." + +backend-migrate-idempotency: + @echo "--> Applying idempotency table migration..." + @test -n "$(IDEMPOTENCY_DATABASE_URL)" || (echo "❌ IDEMPOTENCY_DATABASE_URL is required" && exit 1) + @cd $(BACKEND_COMMAND_DIR) && IDEMPOTENCY_DATABASE_URL="$(IDEMPOTENCY_DATABASE_URL)" npm run migrate:idempotency + @echo "✅ Idempotency migration applied." + +backend-deploy-core: + @echo "--> Deploying core backend service [$(BACKEND_CORE_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_CORE_DIR)" && exit 1) + @test -f $(BACKEND_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_CORE_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_CORE_DIR) --tag $(BACKEND_CORE_IMAGE) --project=$(GCP_PROJECT_ID) + @gcloud run deploy $(BACKEND_CORE_SERVICE_NAME) \ + --image=$(BACKEND_CORE_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \ + $(BACKEND_RUN_AUTH_FLAG) + @echo "✅ Core backend service deployed." + +backend-deploy-commands: + @echo "--> Deploying command backend service [$(BACKEND_COMMAND_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_COMMAND_DIR)" && exit 1) + @test -f $(BACKEND_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_COMMAND_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_COMMAND_DIR) --tag $(BACKEND_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID) + @gcloud run deploy $(BACKEND_COMMAND_SERVICE_NAME) \ + --image=$(BACKEND_COMMAND_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ + $(BACKEND_RUN_AUTH_FLAG) + @echo "✅ Command backend service deployed." + +backend-deploy-workers: + @echo "--> Deploying worker scaffold for [$(ENV)]..." + @if [ ! -d "$(BACKEND_WORKERS_DIR)" ]; then \ + echo "❌ Missing directory: $(BACKEND_WORKERS_DIR)"; \ + exit 1; \ + fi + @if [ -z "$$(find $(BACKEND_WORKERS_DIR) -mindepth 1 ! -name '.keep' -print -quit)" ]; then \ + echo "⚠️ No worker code found in $(BACKEND_WORKERS_DIR). Skipping deployment."; \ + exit 0; \ + fi + @echo "⚠️ Worker deployment is scaffold-only for now." + @echo " Add concrete worker deployment commands once worker code is introduced." + +backend-smoke-core: + @echo "--> Running core smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_CORE_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_CORE_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Core smoke check passed: $$URL/health" + +backend-smoke-commands: + @echo "--> Running commands smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_COMMAND_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_COMMAND_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Commands smoke check passed: $$URL/health" + +backend-logs-core: + @echo "--> Reading logs for core backend service [$(BACKEND_CORE_SERVICE_NAME)]..." + @gcloud run services logs read $(BACKEND_CORE_SERVICE_NAME) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --limit=$(BACKEND_LOG_LIMIT) diff --git a/makefiles/mobile.mk b/makefiles/mobile.mk index 43c3d618..4338cb7b 100644 --- a/makefiles/mobile.mk +++ b/makefiles/mobile.mk @@ -1,6 +1,6 @@ # --- Mobile App Development --- -.PHONY: mobile-install mobile-info mobile-analyze mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart +.PHONY: mobile-install mobile-info mobile-analyze mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart test-e2e test-e2e-setup test-e2e-client test-e2e-staff MOBILE_DIR := apps/mobile @@ -69,3 +69,32 @@ mobile-staff-build: dataconnect-generate-sdk melos exec --scope="core_localization" -- "dart run slang" && \ melos exec --scope="core_localization" -- "dart run build_runner build --delete-conflicting-outputs" && \ melos exec --scope="krowwithus_staff" -- "flutter build $(PLATFORM) --$(MODE) --dart-define-from-file=../../config.dev.json" + +# --- E2E (Maestro) --- +# Set env before running: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD, TEST_CLIENT_COMPANY, TEST_STAFF_PHONE, TEST_STAFF_OTP, TEST_STAFF_SIGNUP_PHONE +# Example: export TEST_CLIENT_EMAIL=legendary@krowd.com TEST_CLIENT_PASSWORD=Demo2026! +# Example: export TEST_STAFF_PHONE=5557654321 TEST_STAFF_OTP=123456 +test-e2e-setup: + @echo "--> Checking Maestro CLI..." + @maestro --version + @echo "--> Maestro OK. Ensure apps are built & installed (see docs/research/maestro-test-run-instructions.md)" + +test-e2e: test-e2e-setup + @echo "--> Running full E2E suite (Client + Staff auth flows)..." + @maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml apps/mobile/apps/client/maestro/auth/sign_up.yaml \ + apps/mobile/apps/staff/maestro/auth/sign_in.yaml apps/mobile/apps/staff/maestro/auth/sign_up.yaml \ + -e TEST_CLIENT_EMAIL="$${TEST_CLIENT_EMAIL}" -e TEST_CLIENT_PASSWORD="$${TEST_CLIENT_PASSWORD}" \ + -e TEST_CLIENT_COMPANY="$${TEST_CLIENT_COMPANY}" -e TEST_STAFF_PHONE="$${TEST_STAFF_PHONE}" \ + -e TEST_STAFF_OTP="$${TEST_STAFF_OTP}" -e TEST_STAFF_SIGNUP_PHONE="$${TEST_STAFF_SIGNUP_PHONE}" + +test-e2e-client: test-e2e-setup + @echo "--> Running Client E2E (sign_in, sign_up)..." + @maestro test apps/mobile/apps/client/maestro/auth/sign_in.yaml apps/mobile/apps/client/maestro/auth/sign_up.yaml \ + -e TEST_CLIENT_EMAIL="$${TEST_CLIENT_EMAIL}" -e TEST_CLIENT_PASSWORD="$${TEST_CLIENT_PASSWORD}" \ + -e TEST_CLIENT_COMPANY="$${TEST_CLIENT_COMPANY}" + +test-e2e-staff: test-e2e-setup + @echo "--> Running Staff E2E (sign_in, sign_up)..." + @maestro test apps/mobile/apps/staff/maestro/auth/sign_in.yaml apps/mobile/apps/staff/maestro/auth/sign_up.yaml \ + -e TEST_STAFF_PHONE="$${TEST_STAFF_PHONE}" -e TEST_STAFF_OTP="$${TEST_STAFF_OTP}" \ + -e TEST_STAFF_SIGNUP_PHONE="$${TEST_STAFF_SIGNUP_PHONE}"